From 0ed4d34329af91fe76588f33de934b3d3c1bda6c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 16 Dec 2024 10:12:16 +0100 Subject: [PATCH] Add inline voice player to the files gallery --- libraries/mediaviewer/impl/build.gradle.kts | 1 + .../impl/gallery/MediaGalleryNode.kt | 22 +- .../impl/gallery/MediaGalleryStateProvider.kt | 1 - .../impl/gallery/MediaGalleryView.kt | 37 +++- .../di/FakeTimelineItemPresenterFactories.kt | 25 +++ .../di/LocalMediaItemPresenterFactories.kt | 17 ++ .../gallery/di/MediaItemEventContentKey.kt | 20 ++ .../gallery/di/MediaItemPresenterFactories.kt | 90 ++++++++ .../gallery/di/MediaItemPresenterFactory.kt | 24 +++ .../impl/gallery/ui/VoiceItemView.kt | 201 +++++++++++++++--- .../gallery/voice/VoiceMessagePresenter.kt | 58 +++++ .../tests/konsist/KonsistPreviewTest.kt | 1 + 12 files changed, 449 insertions(+), 48 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/LocalMediaItemPresenterFactories.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts index 4fa63820d3..395b57df38 100644 --- a/libraries/mediaviewer/impl/build.gradle.kts +++ b/libraries/mediaviewer/impl/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.voiceplayer.api) implementation(projects.services.toolbox.api) api(projects.libraries.mediaviewer.api) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt index ccea1a130e..0c4e3cfebc 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node @@ -18,12 +19,15 @@ import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories +import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactories @ContributesNode(RoomScope::class) class MediaGalleryNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: MediaGalleryPresenter.Factory, + private val mediaItemPresenterFactories: MediaItemPresenterFactories, ) : Node(buildContext, plugins = plugins), MediaGalleryNavigator { private val presenter = presenterFactory.create( @@ -56,12 +60,16 @@ class MediaGalleryNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { - val state = presenter.present() - MediaGalleryView( - state = state, - onBackClick = ::onBackClick, - onItemClick = ::onItemClick, - modifier = modifier, - ) + CompositionLocalProvider( + LocalMediaItemPresenterFactories provides mediaItemPresenterFactories, + ) { + val state = presenter.present() + MediaGalleryView( + state = state, + onBackClick = ::onBackClick, + onItemClick = ::onItemClick, + modifier = modifier, + ) + } } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index c1d1e1ca72..87e7599991 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -64,7 +64,6 @@ open class MediaGalleryStateProvider : PreviewParameterProvider Unit, onItemClick: (MediaItem.Event) -> Unit, ) { + val presenterFactories = LocalMediaItemPresenterFactories.current LazyColumn( modifier = Modifier.fillMaxSize(), ) { @@ -275,12 +282,16 @@ private fun MediaGalleryFilesList( onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) }, onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) }, ) - is MediaItem.Voice -> VoiceItemView( - item, - onShareClick = { eventSink(MediaGalleryEvents.Share(item)) }, - onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) }, - onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) }, - ) + is MediaItem.Voice -> { + val presenter: Presenter = presenterFactories.rememberPresenter(item) + VoiceItemView( + presenter.present(), + item, + onShareClick = { eventSink(MediaGalleryEvents.Share(item)) }, + onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) }, + onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) }, + ) + } is MediaItem.DateSeparator -> DateItemView(item) is MediaItem.Image, is MediaItem.Video -> { @@ -462,9 +473,13 @@ private fun LoadingContent( internal fun MediaGalleryViewPreview( @PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState ) = ElementPreview { - MediaGalleryView( - state = state, - onBackClick = {}, - onItemClick = {}, - ) + CompositionLocalProvider( + LocalMediaItemPresenterFactories provides aFakeMediaItemPresenterFactories(), + ) { + MediaGalleryView( + state = state, + onBackClick = {}, + onItemClick = {}, + ) + } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt new file mode 100644 index 0000000000..bf453c33e0 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt @@ -0,0 +1,25 @@ +/* + * 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.mediaviewer.impl.gallery.di + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import io.element.android.libraries.voiceplayer.api.aVoiceMessageState + +/** + * A fake [MediaItemPresenterFactories] for screenshot tests. + */ +fun aFakeMediaItemPresenterFactories() = MediaItemPresenterFactories( + mapOf( + Pair( + MediaItem.Voice::class.java, + MediaItemPresenterFactory { Presenter { aVoiceMessageState() } }, + ), + ) +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/LocalMediaItemPresenterFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/LocalMediaItemPresenterFactories.kt new file mode 100644 index 0000000000..8138d4c7f7 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/LocalMediaItemPresenterFactories.kt @@ -0,0 +1,17 @@ +/* + * 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.mediaviewer.impl.gallery.di + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Provides a [MediaItemPresenterFactories] to the composition. + */ +val LocalMediaItemPresenterFactories = staticCompositionLocalOf { + MediaItemPresenterFactories(emptyMap()) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt new file mode 100644 index 0000000000..7db70901d3 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt @@ -0,0 +1,20 @@ +/* + * 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.mediaviewer.impl.gallery.di + +import dagger.MapKey +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import kotlin.reflect.KClass + +/** + * Annotation to add a factory of type [MediaItemPresenterFactory] to a + * Dagger map multi binding keyed with a subclass of [MediaItem.Event]. + */ +@Retention(AnnotationRetention.RUNTIME) +@MapKey +annotation class MediaItemEventContentKey(val value: KClass) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt new file mode 100644 index 0000000000..28b79194c7 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt @@ -0,0 +1,90 @@ +/* + * 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.mediaviewer.impl.gallery.di + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.multibindings.Multibinds +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import javax.inject.Inject + +/** + * Dagger module that declares the [MediaItemPresenterFactory] map multi binding. + * + * Its sole purpose is to support the case of an empty map multibinding. + */ +@Module +@ContributesTo(RoomScope::class) +interface MediaItemPresenterFactoriesModule { + @Multibinds + fun multiBindMediaItemPresenterFactories(): @JvmSuppressWildcards Map, MediaItemPresenterFactory<*, *>> +} + +/** + * Room level caching layer for the [MediaItemPresenterFactory] instances. + * + * It will cache the presenter instances in the room scope, so that they can be + * reused across recompositions of the gallery items that happen whenever an item + * goes out of the [LazyColumn] viewport. + */ +@SingleIn(RoomScope::class) +class MediaItemPresenterFactories @Inject constructor( + private val factories: @JvmSuppressWildcards Map, MediaItemPresenterFactory<*, *>>, +) { + private val presenters: MutableMap> = mutableMapOf() + + /** + * Creates and caches a presenter for the given content. + * + * Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding. + * + * @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter. + * @param S The state type produced by this timeline item presenter. + * @param content The [MediaItem.Event] instance to create a presenter for. + * @param contentClass The class of [content]. + * @return An instance of a TimelineItem presenter that will be cached in the room scope. + */ + @Composable + fun rememberPresenter( + content: C, + contentClass: Class, + ): Presenter = remember(content) { + presenters[content]?.let { + @Suppress("UNCHECKED_CAST") + it as Presenter + } ?: factories.getValue(contentClass).let { + @Suppress("UNCHECKED_CAST") + (it as MediaItemPresenterFactory).create(content).apply { + presenters[content] = this + } + } + } +} + +/** + * Creates and caches a presenter for the given content. + * + * Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding. + * + * @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter. + * @param S The state type produced by this timeline item presenter. + * @param content The [MediaItem.Event] instance to create a presenter for. + * @return An instance of a TimelineItem presenter that will be cached in the room scope. + */ +@Composable +inline fun MediaItemPresenterFactories.rememberPresenter( + content: C +): Presenter = rememberPresenter( + content = content, + contentClass = C::class.java +) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt new file mode 100644 index 0000000000..fd621adbfb --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt @@ -0,0 +1,24 @@ +/* + * 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.mediaviewer.impl.gallery.di + +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem + +/** + * A factory for a [Presenter] associated with a timeline item. + * + * Implementations should be annotated with [AssistedFactory] to be created by Dagger. + * + * @param C The timeline item's [MediaItem.Event] subtype. + * @param S The [Presenter]'s state class. + * @return A [Presenter] that produces a state of type [S] for the given content of type [C]. + */ +fun interface MediaItemPresenterFactory { + fun create(content: C): Presenter +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt index 137a94e5d2..472ea6555f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt @@ -20,10 +20,18 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButtonDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -32,15 +40,23 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.HorizontalDivider 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.mediaviewer.impl.gallery.MediaItem +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 io.element.android.libraries.voiceplayer.api.aVoiceMessageState import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.delay @Composable fun VoiceItemView( + state: VoiceMessageState, voice: MediaItem.Voice, onShareClick: () -> Unit, onDownloadClick: () -> Unit, @@ -53,6 +69,7 @@ fun VoiceItemView( .padding(top = 20.dp, start = 16.dp, end = 16.dp), ) { VoiceInfoRow( + state = state, voice = voice, ) val caption = voice.mediaInfo.caption @@ -72,8 +89,13 @@ fun VoiceItemView( @Composable private fun VoiceInfoRow( + state: VoiceMessageState, voice: MediaItem.Voice, ) { + fun playPause() { + state.eventSink(VoiceMessageEvents.PlayPause) + } + Row( modifier = Modifier .clip(RoundedCornerShape(12.dp)) @@ -85,49 +107,155 @@ private fun VoiceInfoRow( .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - modifier = Modifier - .background( - color = ElementTheme.colors.bgCanvasDefault, - shape = CircleShape, - ) - .border( - width = 1.dp, - color = ElementTheme.colors.borderInteractiveSecondary, - shape = CircleShape, - ) - .size(36.dp) - .padding(6.dp), - imageVector = CompoundIcons.PlaySolid(), - tint = ElementTheme.colors.iconSecondary, - contentDescription = null, - ) - voice.duration?.let { - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = voice.duration, - style = ElementTheme.typography.fontBodyMdMedium, - color = ElementTheme.colors.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + when (state.button) { + VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) + VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) + VoiceMessageState.Button.Downloading -> ProgressButton() + VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) + VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) } + Spacer(Modifier.width(8.dp)) + Text( + text = state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Spacer(modifier = Modifier.width(8.dp)) WaveformPlaybackView( modifier = Modifier .weight(1f) .height(34.dp), - playbackProgress = 0f, - showCursor = false, + showCursor = state.showCursor, + playbackProgress = state.progress, waveform = voice.waveform.toPersistentList(), onSeek = { - + state.eventSink(VoiceMessageEvents.Seek(it)) }, seekEnabled = true, ) } } +/** + * Progress button is shown when the voice message is being downloaded. + * + * The progress indicator is optimistic and displays a pause button (which + * indicates the audio is playing) for 2 seconds before revealing the + * actual progress indicator. + */ +@Composable +private fun ProgressButton( + displayImmediately: Boolean = false, +) { + var canDisplay by remember { mutableStateOf(displayImmediately) } + LaunchedEffect(Unit) { + delay(2000L) + canDisplay = true + } + CustomIconButton( + onClick = {}, + enabled = false, + ) { + if (canDisplay) { + CircularProgressIndicator( + modifier = Modifier + .padding(2.dp) + .size(16.dp), + color = ElementTheme.colors.iconSecondary, + strokeWidth = 2.dp, + ) + } else { + ControlIcon( + imageVector = CompoundIcons.PauseSolid(), + contentDescription = stringResource(id = CommonStrings.a11y_pause), + ) + } + } +} + +@Composable +private fun PlayButton( + onClick: () -> Unit, + enabled: Boolean = true, +) { + CustomIconButton( + onClick = onClick, + enabled = enabled, + ) { + ControlIcon( + imageVector = CompoundIcons.PlaySolid(), + contentDescription = stringResource(id = CommonStrings.a11y_play), + ) + } +} + +@Composable +private fun PauseButton( + onClick: () -> Unit, +) { + CustomIconButton( + onClick = onClick, + ) { + ControlIcon( + imageVector = CompoundIcons.PauseSolid(), + contentDescription = stringResource(id = CommonStrings.a11y_pause), + ) + } +} + +@Composable +private fun RetryButton( + onClick: () -> Unit, +) { + CustomIconButton( + onClick = onClick, + ) { + ControlIcon( + imageVector = CompoundIcons.Restart(), + contentDescription = stringResource(id = CommonStrings.action_retry), + ) + } +} + +@Composable +private fun ControlIcon( + imageVector: ImageVector, + contentDescription: String?, +) { + Icon( + modifier = Modifier.padding(vertical = 10.dp), + imageVector = imageVector, + contentDescription = contentDescription, + ) +} + +@Composable +private fun CustomIconButton( + onClick: () -> Unit, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier + .background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape) + .border( + width = 1.dp, + color = ElementTheme.colors.borderInteractiveSecondary, + shape = CircleShape, + ) + .size(36.dp), + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors( + contentColor = ElementTheme.colors.iconSecondary, + disabledContentColor = ElementTheme.colors.iconDisabled, + ), + content = content, + ) +} + @Composable private fun Caption(caption: String) { Text( @@ -183,9 +311,24 @@ internal fun VoiceItemViewPreview( @PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice, ) = ElementPreview { VoiceItemView( + state = aVoiceMessageState(), voice = voice, onShareClick = {}, onDownloadClick = {}, onInfoClick = {}, ) } + +@PreviewsDayNight +@Composable +internal fun VoiceItemViewPlayPreview( + @PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState, +) = ElementPreview { + VoiceItemView( + state = state, + voice = aMediaItemVoice(), + onShareClick = {}, + onDownloadClick = {}, + onInfoClick = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt new file mode 100644 index 0000000000..9f5a6593ed --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt @@ -0,0 +1,58 @@ +/* + * 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.mediaviewer.impl.gallery.voice + +import androidx.compose.runtime.Composable +import com.squareup.anvil.annotations.ContributesTo +import dagger.Binds +import dagger.Module +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.multibindings.IntoMap +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemEventContentKey +import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactory +import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory +import io.element.android.libraries.voiceplayer.api.VoiceMessageState +import kotlin.time.Duration + +@Module +@ContributesTo(RoomScope::class) +interface VoiceMessagePresenterModule { + @Binds + @IntoMap + @MediaItemEventContentKey(MediaItem.Voice::class) + fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): MediaItemPresenterFactory<*, *> +} + +class VoiceMessagePresenter @AssistedInject constructor( + voiceMessagePresenterFactory: VoiceMessagePresenterFactory, + @Assisted private val item: MediaItem.Voice, +) : Presenter { + @AssistedFactory + fun interface Factory : MediaItemPresenterFactory { + override fun create(content: MediaItem.Voice): VoiceMessagePresenter + } + + private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter( + eventId = item.eventId, + mediaSource = item.mediaSource, + mimeType = item.mediaInfo.mimeType, + filename = item.mediaInfo.filename, + // TODO Get the duration for the fallback? + duration = Duration.ZERO, + ) + + @Composable + override fun present(): VoiceMessageState { + return presenter.present() + } +} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 8d26082157..1235223337 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -128,6 +128,7 @@ class KonsistPreviewTest { "TimelineVideoWithCaptionRowPreview", "TimelineViewMessageShieldPreview", "UserAvatarColorsPreview", + "VoiceItemViewPlayPreview", ) .assertTrue( additionalMessage = "Functions for Preview should be named like this: Preview. " +