From f74258d794196b5703e4fd96b312722a85655f1b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 13 Dec 2024 15:30:24 +0100 Subject: [PATCH] Extract voice message player to its own module --- features/messages/impl/build.gradle.kts | 1 + .../event/TimelineItemEventContentView.kt | 2 +- .../components/event/TimelineItemVoiceView.kt | 6 +- .../di/FakeTimelineItemPresenterFactories.kt | 4 +- .../composer/VoiceMessageComposerPresenter.kt | 2 +- .../timeline/VoiceMessagePresenter.kt | 107 +-------------- .../VoiceMessageComposerPresenterTest.kt | 2 +- libraries/voiceplayer/api/build.gradle.kts | 23 ++++ .../voiceplayer/api}/VoiceMessageEvents.kt | 2 +- .../voiceplayer/api}/VoiceMessageException.kt | 6 +- .../api/VoiceMessagePresenterFactory.kt | 23 ++++ .../voiceplayer/api}/VoiceMessageState.kt | 2 +- .../api}/VoiceMessageStateProvider.kt | 2 +- libraries/voiceplayer/impl/build.gradle.kts | 43 ++++++ .../DefaultVoiceMessagePresenterFactory.kt | 50 +++++++ .../impl}/VoiceMessageMediaRepo.kt | 2 +- .../voiceplayer/impl}/VoiceMessagePlayer.kt | 2 +- .../voiceplayer/impl/VoiceMessagePresenter.kt | 124 ++++++++++++++++++ .../impl}/DefaultVoiceMessageMediaRepoTest.kt | 2 +- .../impl}/DefaultVoiceMessagePlayerTest.kt | 2 +- .../impl}/FakeVoiceMessageMediaRepo.kt | 2 +- .../impl}/VoiceMessagePresenterTest.kt | 52 ++++---- .../kotlin/extension/DependencyHandleScope.kt | 1 + 23 files changed, 321 insertions(+), 141 deletions(-) create mode 100644 libraries/voiceplayer/api/build.gradle.kts rename {features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api}/VoiceMessageEvents.kt (80%) rename {features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages => libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api}/VoiceMessageException.kt (82%) create mode 100644 libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt rename {features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api}/VoiceMessageState.kt (86%) rename {features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api}/VoiceMessageStateProvider.kt (95%) create mode 100644 libraries/voiceplayer/impl/build.gradle.kts create mode 100644 libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePresenterFactory.kt rename {features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl}/VoiceMessageMediaRepo.kt (98%) rename {features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl}/VoiceMessagePlayer.kt (98%) create mode 100644 libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt rename {features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl}/DefaultVoiceMessageMediaRepoTest.kt (98%) rename {features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl}/DefaultVoiceMessagePlayerTest.kt (99%) rename {features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl}/FakeVoiceMessageMediaRepo.kt (89%) rename {features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline => libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl}/VoiceMessagePresenterTest.kt (85%) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index e87d90cbdf..f5b6520996 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation(projects.libraries.permissions.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.voiceplayer.api) implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.mediaplayer.api) implementation(projects.libraries.uiUtils) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 35a8cda293..3fa786f902 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -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( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index f5cee592e2..365b97f9fc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt index 28a0ff094f..47b2b4eba5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt @@ -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. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt index cc6d134802..68ebead3b6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt index 038de6730d..f515fca9d6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt @@ -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 { @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.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() } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt index a7cbaaffd2..f1389962b0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenterTest.kt @@ -18,7 +18,6 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer import io.element.android.features.messages.impl.messagecomposer.aReplyMode -import io.element.android.features.messages.impl.voicemessages.VoiceMessageException import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.media.AudioInfo @@ -36,6 +35,7 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode 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.test.FakeVoiceRecorder import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule diff --git a/libraries/voiceplayer/api/build.gradle.kts b/libraries/voiceplayer/api/build.gradle.kts new file mode 100644 index 0000000000..5beb8ebbc0 --- /dev/null +++ b/libraries/voiceplayer/api/build.gradle.kts @@ -0,0 +1,23 @@ +import extension.setupAnvil + +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.voiceplayer.api" +} + +setupAnvil() + +dependencies { + implementation(libs.androidx.annotationjvm) + implementation(libs.coroutines.core) + implementation(projects.libraries.matrix.api) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt similarity index 80% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt rename to libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt index d124e57dcc..4ea61b8547 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.api sealed interface VoiceMessageEvents { data object PlayPause : VoiceMessageEvents diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageException.kt similarity index 82% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt rename to libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageException.kt index ff3c5542f6..c35ec0b14c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageException.kt @@ -5,17 +5,19 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages +package io.element.android.libraries.voiceplayer.api -internal sealed class VoiceMessageException : Exception() { +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? diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt new file mode 100644 index 0000000000..1e5c706b10 --- /dev/null +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessagePresenterFactory.kt @@ -0,0 +1,23 @@ +/* + * Copyright 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.libraries.voiceplayer.api + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource +import kotlin.time.Duration + +interface VoiceMessagePresenterFactory { + fun createVoiceMessagePresenter( + eventId: EventId?, + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + duration: Duration, + ): Presenter +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt similarity index 86% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt rename to libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt index a7d0c15c13..5200614d57 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.api data class VoiceMessageState( val button: Button, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt similarity index 95% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt rename to libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt index 75d00240a2..a06181a4ee 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.api import androidx.compose.ui.tooling.preview.PreviewParameterProvider diff --git a/libraries/voiceplayer/impl/build.gradle.kts b/libraries/voiceplayer/impl/build.gradle.kts new file mode 100644 index 0000000000..155190e3bb --- /dev/null +++ b/libraries/voiceplayer/impl/build.gradle.kts @@ -0,0 +1,43 @@ +import extension.setupAnvil + +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.libraries.voiceplayer.impl" +} + +setupAnvil() + +dependencies { + api(projects.libraries.voiceplayer.api) + + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.mediaplayer.api) + implementation(projects.libraries.uiUtils) + implementation(projects.services.analytics.api) + + implementation(libs.androidx.annotationjvm) + implementation(libs.coroutines.core) + + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.junit) + testImplementation(libs.test.truth) + testImplementation(libs.test.mockk) + testImplementation(libs.test.turbine) + testImplementation(libs.coroutines.core) + testImplementation(libs.coroutines.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.mediaplayer.test) + testImplementation(projects.services.analytics.test) + testImplementation(projects.tests.testutils) +} diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePresenterFactory.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePresenterFactory.kt new file mode 100644 index 0000000000..48807f5027 --- /dev/null +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePresenterFactory.kt @@ -0,0 +1,50 @@ +/* + * Copyright 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.libraries.voiceplayer.impl + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.architecture.Presenter +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.voiceplayer.api.VoiceMessagePresenterFactory +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject +import kotlin.time.Duration + +@ContributesBinding(RoomScope::class) +class DefaultVoiceMessagePresenterFactory @Inject constructor( + private val analyticsService: AnalyticsService, + private val scope: CoroutineScope, + private val voiceMessagePlayerFactory: VoiceMessagePlayer.Factory, +) : VoiceMessagePresenterFactory { + override fun createVoiceMessagePresenter( + eventId: EventId?, + mediaSource: MediaSource, + mimeType: String?, + filename: String?, + duration: Duration, + ): Presenter { + val player = voiceMessagePlayerFactory.create( + eventId = eventId, + mediaSource = mediaSource, + mimeType = mimeType, + filename = filename, + ) + + return VoiceMessagePresenter( + analyticsService = analyticsService, + scope = scope, + player = player, + eventId = eventId, + duration = duration, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt similarity index 98% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt rename to libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt index f1d8e5f987..71357a2559 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessageMediaRepo.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.impl import com.squareup.anvil.annotations.ContributesBinding import dagger.assisted.Assisted diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt similarity index 98% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt rename to libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt index aa339e3365..308edd0a51 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePlayer.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.impl import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.mimetype.MimeTypes diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt new file mode 100644 index 0000000000..0786d2d7ed --- /dev/null +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenter.kt @@ -0,0 +1,124 @@ +/* + * Copyright 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.libraries.voiceplayer.impl + +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 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.matrix.api.core.EventId +import io.element.android.libraries.ui.utils.time.formatShort +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageException +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class VoiceMessagePresenter( + private val analyticsService: AnalyticsService, + private val scope: CoroutineScope, + private val player: VoiceMessagePlayer, + private val eventId: EventId?, + private val duration: Duration, +) : Presenter { + private val play = mutableStateOf>(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 { + 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 ?: 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) }, + ) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt similarity index 98% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt rename to libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt index fcf1998097..4c7b176fa5 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessageMediaRepoTest.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.impl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessagePlayerTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePlayerTest.kt similarity index 99% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessagePlayerTest.kt rename to libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePlayerTest.kt index 9a82b46776..fdc9b2ee50 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessagePlayerTest.kt +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/DefaultVoiceMessagePlayerTest.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.impl import app.cash.turbine.TurbineTestContext import app.cash.turbine.test diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/FakeVoiceMessageMediaRepo.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/FakeVoiceMessageMediaRepo.kt similarity index 89% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/FakeVoiceMessageMediaRepo.kt rename to libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/FakeVoiceMessageMediaRepo.kt index 8d2f5b88ac..8867af8287 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/FakeVoiceMessageMediaRepo.kt +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/FakeVoiceMessageMediaRepo.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.impl import io.element.android.tests.testutils.simulateLongTask import java.io.File diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenterTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt similarity index 85% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenterTest.kt rename to libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt index ceedf0948f..59b1891962 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenterTest.kt +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt @@ -5,21 +5,25 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.features.messages.impl.voicemessages.timeline +package io.element.android.libraries.voiceplayer.impl import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent -import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent -import io.element.android.features.messages.impl.voicemessages.VoiceMessageException +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageException +import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds class VoiceMessagePresenterTest { @@ -41,7 +45,7 @@ class VoiceMessagePresenterTest { fun `pressing play downloads and plays`() = runTest { val presenter = createVoiceMessagePresenter( mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), - content = aTimelineItemVoiceContent(duration = 2_000.milliseconds), + duration = 2_000.milliseconds, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -79,7 +83,7 @@ class VoiceMessagePresenterTest { mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true }, analyticsService = analyticsService, - content = aTimelineItemVoiceContent(duration = 2_000.milliseconds), + duration = 2_000.milliseconds, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -115,7 +119,7 @@ class VoiceMessagePresenterTest { fun `pressing pause while playing pauses`() = runTest { val presenter = createVoiceMessagePresenter( mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), - content = aTimelineItemVoiceContent(duration = 2_000.milliseconds), + duration = 2_000.milliseconds, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -147,7 +151,7 @@ class VoiceMessagePresenterTest { @Test fun `content with null eventId shows disabled button`() = runTest { val presenter = createVoiceMessagePresenter( - content = aTimelineItemVoiceContent(eventId = null), + eventId = null, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -164,7 +168,7 @@ class VoiceMessagePresenterTest { fun `seeking before play`() = runTest { val presenter = createVoiceMessagePresenter( mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), - content = aTimelineItemVoiceContent(duration = 10_000.milliseconds), + duration = 10_000.milliseconds, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -188,7 +192,7 @@ class VoiceMessagePresenterTest { @Test fun `seeking after play`() = runTest { val presenter = createVoiceMessagePresenter( - content = aTimelineItemVoiceContent(duration = 10_000.milliseconds), + duration = 10_000.milliseconds, ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -224,19 +228,23 @@ fun TestScope.createVoiceMessagePresenter( mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(), voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(), analyticsService: AnalyticsService = FakeAnalyticsService(), - content: TimelineItemVoiceContent = aTimelineItemVoiceContent(), + eventId: EventId? = EventId("\$anEventId"), + filename: String = "filename doesn't really matter for a voice message", + duration: Duration = 61_000.milliseconds, + contentUri: String = "mxc://matrix.org/1234567890abcdefg", + mimeType: String = MimeTypes.Ogg, + mediaSource: MediaSource = MediaSource(contentUri), ) = VoiceMessagePresenter( - voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, filename -> - DefaultVoiceMessagePlayer( - mediaPlayer = mediaPlayer, - voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo }, - eventId = eventId, - mediaSource = mediaSource, - mimeType = mimeType, - filename = filename - ) - }, analyticsService = analyticsService, scope = this, - content = content, + player = DefaultVoiceMessagePlayer( + mediaPlayer = mediaPlayer, + voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo }, + eventId = eventId, + mediaSource = mediaSource, + mimeType = mimeType, + filename = filename + ), + eventId = eventId, + duration = duration, ) diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index f54cdb81ca..4d24ffebfd 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -83,6 +83,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:textcomposer:impl")) implementation(project(":libraries:roomselect:impl")) implementation(project(":libraries:cryptography:impl")) + implementation(project(":libraries:voiceplayer:impl")) implementation(project(":libraries:voicerecorder:impl")) implementation(project(":libraries:mediaplayer:impl")) implementation(project(":libraries:mediaviewer:impl"))