Add inline voice player to the files gallery

This commit is contained in:
Benoit Marty 2024-12-16 10:12:16 +01:00
parent df72ab6a86
commit 0ed4d34329
12 changed files with 449 additions and 48 deletions

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
api(projects.libraries.mediaviewer.api) api(projects.libraries.mediaviewer.api)

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.gallery package io.element.android.libraries.mediaviewer.impl.gallery
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node 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.anvilannotations.ContributesNode
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
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactories
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
class MediaGalleryNode @AssistedInject constructor( class MediaGalleryNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: MediaGalleryPresenter.Factory, presenterFactory: MediaGalleryPresenter.Factory,
private val mediaItemPresenterFactories: MediaItemPresenterFactories,
) : Node(buildContext, plugins = plugins), ) : Node(buildContext, plugins = plugins),
MediaGalleryNavigator { MediaGalleryNavigator {
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
@ -56,12 +60,16 @@ class MediaGalleryNode @AssistedInject constructor(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() CompositionLocalProvider(
MediaGalleryView( LocalMediaItemPresenterFactories provides mediaItemPresenterFactories,
state = state, ) {
onBackClick = ::onBackClick, val state = presenter.present()
onItemClick = ::onItemClick, MediaGalleryView(
modifier = modifier, state = state,
) onBackClick = ::onBackClick,
onItemClick = ::onItemClick,
modifier = modifier,
)
}
} }
} }

View file

@ -64,7 +64,6 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
id = UniqueId("2"), id = UniqueId("2"),
formattedDate = "September 2004", formattedDate = "September 2004",
), ),
aMediaItemFile(id = UniqueId("3")),
aMediaItemAudio(id = UniqueId("4")), aMediaItemAudio(id = UniqueId("4")),
aMediaItemVoice( aMediaItemVoice(
id = UniqueId("5"), id = UniqueId("5"),

View file

@ -27,6 +27,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
@ -40,6 +41,7 @@ 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.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.async.AsyncFailure import io.element.android.libraries.designsystem.components.async.AsyncFailure
@ -60,12 +62,16 @@ import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.aFakeMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.rememberPresenter
import io.element.android.libraries.mediaviewer.impl.gallery.ui.AudioItemView import io.element.android.libraries.mediaviewer.impl.gallery.ui.AudioItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.DateItemView import io.element.android.libraries.mediaviewer.impl.gallery.ui.DateItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlin.math.max import kotlin.math.max
@ -256,6 +262,7 @@ private fun MediaGalleryFilesList(
eventSink: (MediaGalleryEvents) -> Unit, eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit, onItemClick: (MediaItem.Event) -> Unit,
) { ) {
val presenterFactories = LocalMediaItemPresenterFactories.current
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
@ -275,12 +282,16 @@ private fun MediaGalleryFilesList(
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) }, onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) }, onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
) )
is MediaItem.Voice -> VoiceItemView( is MediaItem.Voice -> {
item, val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) }, VoiceItemView(
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) }, presenter.present(),
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) }, item,
) onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
)
}
is MediaItem.DateSeparator -> DateItemView(item) is MediaItem.DateSeparator -> DateItemView(item)
is MediaItem.Image, is MediaItem.Image,
is MediaItem.Video -> { is MediaItem.Video -> {
@ -462,9 +473,13 @@ private fun LoadingContent(
internal fun MediaGalleryViewPreview( internal fun MediaGalleryViewPreview(
@PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState @PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState
) = ElementPreview { ) = ElementPreview {
MediaGalleryView( CompositionLocalProvider(
state = state, LocalMediaItemPresenterFactories provides aFakeMediaItemPresenterFactories(),
onBackClick = {}, ) {
onItemClick = {}, MediaGalleryView(
) state = state,
onBackClick = {},
onItemClick = {},
)
}
} }

View file

@ -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<MediaItem.Voice, VoiceMessageState> { Presenter { aVoiceMessageState() } },
),
)
)

View file

@ -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())
}

View file

@ -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<out MediaItem.Event>)

View file

@ -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<Class<out MediaItem.Event>, 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<Class<out MediaItem.Event>, MediaItemPresenterFactory<*, *>>,
) {
private val presenters: MutableMap<MediaItem.Event, Presenter<*>> = 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 <C : MediaItem.Event, S : Any> rememberPresenter(
content: C,
contentClass: Class<C>,
): Presenter<S> = remember(content) {
presenters[content]?.let {
@Suppress("UNCHECKED_CAST")
it as Presenter<S>
} ?: factories.getValue(contentClass).let {
@Suppress("UNCHECKED_CAST")
(it as MediaItemPresenterFactory<C, S>).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 <reified C : MediaItem.Event, S : Any> MediaItemPresenterFactories.rememberPresenter(
content: C
): Presenter<S> = rememberPresenter(
content = content,
contentClass = C::class.java
)

View file

@ -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<C : MediaItem.Event, S : Any> {
fun create(content: C): Presenter<S>
}

View file

@ -20,10 +20,18 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp 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.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
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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon 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.IconButton
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.gallery.MediaItem 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.collections.immutable.toPersistentList
import kotlinx.coroutines.delay
@Composable @Composable
fun VoiceItemView( fun VoiceItemView(
state: VoiceMessageState,
voice: MediaItem.Voice, voice: MediaItem.Voice,
onShareClick: () -> Unit, onShareClick: () -> Unit,
onDownloadClick: () -> Unit, onDownloadClick: () -> Unit,
@ -53,6 +69,7 @@ fun VoiceItemView(
.padding(top = 20.dp, start = 16.dp, end = 16.dp), .padding(top = 20.dp, start = 16.dp, end = 16.dp),
) { ) {
VoiceInfoRow( VoiceInfoRow(
state = state,
voice = voice, voice = voice,
) )
val caption = voice.mediaInfo.caption val caption = voice.mediaInfo.caption
@ -72,8 +89,13 @@ fun VoiceItemView(
@Composable @Composable
private fun VoiceInfoRow( private fun VoiceInfoRow(
state: VoiceMessageState,
voice: MediaItem.Voice, voice: MediaItem.Voice,
) { ) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
}
Row( Row(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
@ -85,49 +107,155 @@ private fun VoiceInfoRow(
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( when (state.button) {
modifier = Modifier VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
.background( VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
color = ElementTheme.colors.bgCanvasDefault, VoiceMessageState.Button.Downloading -> ProgressButton()
shape = CircleShape, VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
) VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
.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,
)
} }
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)) Spacer(modifier = Modifier.width(8.dp))
WaveformPlaybackView( WaveformPlaybackView(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(34.dp), .height(34.dp),
playbackProgress = 0f, showCursor = state.showCursor,
showCursor = false, playbackProgress = state.progress,
waveform = voice.waveform.toPersistentList(), waveform = voice.waveform.toPersistentList(),
onSeek = { onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it))
}, },
seekEnabled = true, 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 @Composable
private fun Caption(caption: String) { private fun Caption(caption: String) {
Text( Text(
@ -183,9 +311,24 @@ internal fun VoiceItemViewPreview(
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice, @PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
) = ElementPreview { ) = ElementPreview {
VoiceItemView( VoiceItemView(
state = aVoiceMessageState(),
voice = voice, voice = voice,
onShareClick = {}, onShareClick = {},
onDownloadClick = {}, onDownloadClick = {},
onInfoClick = {}, onInfoClick = {},
) )
} }
@PreviewsDayNight
@Composable
internal fun VoiceItemViewPlayPreview(
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
) = ElementPreview {
VoiceItemView(
state = state,
voice = aMediaItemVoice(),
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
)
}

View file

@ -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<VoiceMessageState> {
@AssistedFactory
fun interface Factory : MediaItemPresenterFactory<MediaItem.Voice, VoiceMessageState> {
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()
}
}

View file

@ -128,6 +128,7 @@ class KonsistPreviewTest {
"TimelineVideoWithCaptionRowPreview", "TimelineVideoWithCaptionRowPreview",
"TimelineViewMessageShieldPreview", "TimelineViewMessageShieldPreview",
"UserAvatarColorsPreview", "UserAvatarColorsPreview",
"VoiceItemViewPlayPreview",
) )
.assertTrue( .assertTrue(
additionalMessage = "Functions for Preview should be named like this: <ViewUnderPreview>Preview. " + additionalMessage = "Functions for Preview should be named like this: <ViewUnderPreview>Preview. " +