Merge pull request #4045 from element-hq/feature/bma/inlinePlayer
Inline voice message player in the files gallery.
This commit is contained in:
commit
270fcb9029
52 changed files with 825 additions and 102 deletions
|
|
@ -125,3 +125,24 @@ fun anAudioMediaInfo(
|
|||
dateSentFull = dateSentFull,
|
||||
waveform = waveForm,
|
||||
)
|
||||
|
||||
fun aVoiceMediaInfo(
|
||||
filename: String = "a voice file.ogg",
|
||||
caption: String? = null,
|
||||
senderName: String? = null,
|
||||
dateSent: String? = null,
|
||||
dateSentFull: String? = null,
|
||||
waveForm: List<Float>? = null,
|
||||
): MediaInfo = MediaInfo(
|
||||
filename = filename,
|
||||
caption = caption,
|
||||
mimeType = MimeTypes.Ogg,
|
||||
formattedFileSize = "3MB",
|
||||
fileExtension = "ogg",
|
||||
senderId = UserId("@alice:server.org"),
|
||||
senderName = senderName,
|
||||
senderAvatar = null,
|
||||
dateSent = dateSent,
|
||||
dateSentFull = dateSentFull,
|
||||
waveform = waveForm,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -104,7 +105,6 @@ class EventItemFactory @Inject constructor(
|
|||
),
|
||||
mediaSource = type.source,
|
||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||
waveform = null,
|
||||
)
|
||||
is FileMessageType -> MediaItem.File(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
|
|
@ -182,7 +182,7 @@ class EventItemFactory @Inject constructor(
|
|||
thumbnailSource = type.info?.thumbnailSource,
|
||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||
)
|
||||
is VoiceMessageType -> MediaItem.Audio(
|
||||
is VoiceMessageType -> MediaItem.Voice(
|
||||
id = currentTimelineItem.uniqueId,
|
||||
eventId = currentTimelineItem.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
|
|
@ -200,7 +200,7 @@ class EventItemFactory @Inject constructor(
|
|||
),
|
||||
mediaSource = type.source,
|
||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||
waveform = type.details?.waveform,
|
||||
waveform = type.details?.waveform ?: persistentListOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Plugin>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ class MediaGalleryPresenter @AssistedInject constructor(
|
|||
is MediaItem.Video -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
|
||||
is MediaItem.Audio -> null
|
||||
is MediaItem.File -> null
|
||||
is MediaItem.Voice -> null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
|
|||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVoice
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryState> {
|
||||
|
|
@ -63,9 +64,8 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
|||
id = UniqueId("2"),
|
||||
formattedDate = "September 2004",
|
||||
),
|
||||
aMediaItemFile(id = UniqueId("3")),
|
||||
aMediaItemAudio(id = UniqueId("4")),
|
||||
aMediaItemAudio(
|
||||
aMediaItemVoice(
|
||||
id = UniqueId("5"),
|
||||
waveform = aWaveForm(),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import androidx.compose.foundation.pager.rememberPagerState
|
|||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.tokens.generated.CompoundIcons
|
||||
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.PageTitle
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||
|
|
@ -60,11 +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.MediaDeleteConfirmationBottomSheet
|
||||
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.DateItemView
|
||||
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.VideoItemView
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlin.math.max
|
||||
|
||||
|
|
@ -255,6 +262,7 @@ private fun MediaGalleryFilesList(
|
|||
eventSink: (MediaGalleryEvents) -> Unit,
|
||||
onItemClick: (MediaItem.Event) -> Unit,
|
||||
) {
|
||||
val presenterFactories = LocalMediaItemPresenterFactories.current
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
|
|
@ -274,6 +282,16 @@ private fun MediaGalleryFilesList(
|
|||
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
|
||||
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
|
||||
)
|
||||
is MediaItem.Voice -> {
|
||||
val presenter: Presenter<VoiceMessageState> = 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 -> {
|
||||
|
|
@ -332,6 +350,9 @@ private fun MediaGalleryImageGrid(
|
|||
is MediaItem.Audio -> {
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.Voice -> {
|
||||
// Should not happen
|
||||
}
|
||||
is MediaItem.File -> {
|
||||
// Should not happen
|
||||
}
|
||||
|
|
@ -452,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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,15 @@ sealed interface MediaItem {
|
|||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val duration: String?,
|
||||
val waveform: ImmutableList<Float>?,
|
||||
) : Event
|
||||
|
||||
data class Voice(
|
||||
val id: UniqueId,
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val duration: String?,
|
||||
val waveform: ImmutableList<Float>,
|
||||
) : Event
|
||||
|
||||
data class File(
|
||||
|
|
@ -77,6 +85,7 @@ fun MediaItem.id(): UniqueId {
|
|||
is MediaItem.Video -> id
|
||||
is MediaItem.File -> id
|
||||
is MediaItem.Audio -> id
|
||||
is MediaItem.Voice -> id
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +95,7 @@ fun MediaItem.Event.eventId(): EventId? {
|
|||
is MediaItem.Video -> eventId
|
||||
is MediaItem.File -> eventId
|
||||
is MediaItem.Audio -> eventId
|
||||
is MediaItem.Voice -> eventId
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +105,7 @@ fun MediaItem.Event.mediaInfo(): MediaInfo {
|
|||
is MediaItem.Video -> mediaInfo
|
||||
is MediaItem.File -> mediaInfo
|
||||
is MediaItem.Audio -> mediaInfo
|
||||
is MediaItem.Voice -> mediaInfo
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +115,7 @@ fun MediaItem.Event.mediaSource(): MediaSource {
|
|||
is MediaItem.Video -> mediaSource
|
||||
is MediaItem.File -> mediaSource
|
||||
is MediaItem.Audio -> mediaSource
|
||||
is MediaItem.Voice -> mediaSource
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,5 +125,6 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? {
|
|||
is MediaItem.Video -> thumbnailSource
|
||||
is MediaItem.File -> null
|
||||
is MediaItem.Audio -> null
|
||||
is MediaItem.Voice -> null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class MediaItemsPostProcessor @Inject constructor() {
|
|||
imageAndVideoItemsSubList.add(0, item)
|
||||
}
|
||||
is MediaItem.Audio,
|
||||
is MediaItem.Voice,
|
||||
is MediaItem.File -> {
|
||||
fileItemsSublist.add(0, item)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() } },
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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>)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -21,6 +20,8 @@ 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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.GraphicEq
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -31,7 +32,6 @@ import androidx.compose.ui.unit.dp
|
|||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.core.extensions.withBrackets
|
||||
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.HorizontalDivider
|
||||
|
|
@ -39,7 +39,6 @@ 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 kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
@Composable
|
||||
fun AudioItemView(
|
||||
|
|
@ -94,18 +93,12 @@ private fun FilenameRow(
|
|||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgCanvasDefault,
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(36.dp)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.PlaySolid(),
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
imageVector = Icons.Outlined.GraphicEq,
|
||||
contentDescription = null,
|
||||
)
|
||||
audio.duration?.let {
|
||||
|
|
@ -119,34 +112,20 @@ private fun FilenameRow(
|
|||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
val waveform = audio.waveform
|
||||
if (waveform == null) {
|
||||
Text(
|
||||
text = audio.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = audio.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = audio.mediaInfo.filename,
|
||||
modifier = Modifier.weight(1f),
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val formattedSize = audio.mediaInfo.formattedFileSize
|
||||
if (formattedSize.isNotEmpty()) {
|
||||
Text(
|
||||
text = formattedSize.withBrackets(),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
WaveformPlaybackView(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(34.dp),
|
||||
playbackProgress = 0f,
|
||||
showCursor = false,
|
||||
waveform = waveform.toPersistentList(),
|
||||
onSeek = {},
|
||||
seekEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ fun FileItemView(
|
|||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
|
||||
) {
|
||||
FilenameRow(
|
||||
file = file,
|
||||
|
|
@ -78,24 +78,24 @@ private fun FilenameRow(
|
|||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
.background(
|
||||
color = ElementTheme.colors.bgActionSecondaryRest,
|
||||
shape = CircleShape,
|
||||
)
|
||||
.size(32.dp)
|
||||
.padding(6.dp),
|
||||
imageVector = CompoundIcons.Attachment(),
|
||||
contentDescription = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,12 +9,10 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
|||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.core.preview.loremIpsum
|
||||
import io.element.android.libraries.designsystem.components.media.aWaveForm
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
|
||||
override val values: Sequence<MediaItem.Audio>
|
||||
|
|
@ -27,9 +25,6 @@ class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
|
|||
aMediaItemAudio(
|
||||
caption = loremIpsum,
|
||||
),
|
||||
aMediaItemAudio(
|
||||
waveform = aWaveForm(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +33,6 @@ fun aMediaItemAudio(
|
|||
filename: String = "filename",
|
||||
caption: String? = null,
|
||||
duration: String? = "1:23",
|
||||
waveform: List<Float>? = null,
|
||||
): MediaItem.Audio {
|
||||
return MediaItem.Audio(
|
||||
id = id,
|
||||
|
|
@ -49,6 +43,5 @@ fun aMediaItemAudio(
|
|||
),
|
||||
mediaSource = MediaSource(""),
|
||||
duration = duration,
|
||||
waveform = waveform?.toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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.ui
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.core.preview.loremIpsum
|
||||
import io.element.android.libraries.designsystem.components.media.aWaveForm
|
||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
|
||||
override val values: Sequence<MediaItem.Voice>
|
||||
get() = sequenceOf(
|
||||
aMediaItemVoice(),
|
||||
aMediaItemVoice(
|
||||
filename = "A long filename that should be truncated.ogg",
|
||||
caption = "A caption",
|
||||
),
|
||||
aMediaItemVoice(
|
||||
caption = loremIpsum,
|
||||
),
|
||||
aMediaItemVoice(
|
||||
waveform = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aMediaItemVoice(
|
||||
id: UniqueId = UniqueId("fileId"),
|
||||
filename: String = "filename.ogg",
|
||||
caption: String? = null,
|
||||
duration: String? = "1:23",
|
||||
waveform: List<Float> = aWaveForm(),
|
||||
): MediaItem.Voice {
|
||||
return MediaItem.Voice(
|
||||
id = id,
|
||||
eventId = null,
|
||||
mediaInfo = aVoiceMediaInfo(
|
||||
filename = filename,
|
||||
caption = caption,
|
||||
),
|
||||
mediaSource = MediaSource(""),
|
||||
duration = duration,
|
||||
waveform = waveform.toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
/*
|
||||
* 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.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
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,
|
||||
onInfoClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
|
||||
) {
|
||||
VoiceInfoRow(
|
||||
state = state,
|
||||
voice = voice,
|
||||
)
|
||||
val caption = voice.mediaInfo.caption
|
||||
if (caption != null) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Caption(caption)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ActionIconsRow(
|
||||
onShareClick = onShareClick,
|
||||
onDownloadClick = onDownloadClick,
|
||||
onInfoClick = onInfoClick,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceInfoRow(
|
||||
state: VoiceMessageState,
|
||||
voice: MediaItem.Voice,
|
||||
) {
|
||||
fun playPause() {
|
||||
state.eventSink(VoiceMessageEvents.PlayPause)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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),
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = caption,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionIconsRow(
|
||||
onShareClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onShareClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ShareAndroid(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDownloadClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Download(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onInfoClick,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Info(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -263,7 +263,6 @@ class DefaultEventItemFactoryTest {
|
|||
),
|
||||
mediaSource = MediaSource(""),
|
||||
duration = "7:36",
|
||||
waveform = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -348,7 +347,7 @@ class DefaultEventItemFactoryTest {
|
|||
)
|
||||
)
|
||||
assertThat(result).isEqualTo(
|
||||
MediaItem.Audio(
|
||||
MediaItem.Voice(
|
||||
id = A_UNIQUE_ID,
|
||||
eventId = AN_EVENT_ID,
|
||||
mediaInfo = MediaInfo(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
|
|||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVoice
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -27,6 +28,9 @@ class MediaItemsPostProcessorTest {
|
|||
private val audio1 = aMediaItemAudio(id = UniqueId("1"))
|
||||
private val audio2 = aMediaItemAudio(id = UniqueId("2"))
|
||||
private val audio3 = aMediaItemAudio(id = UniqueId("3"))
|
||||
private val voice1 = aMediaItemVoice(id = UniqueId("1"))
|
||||
private val voice2 = aMediaItemVoice(id = UniqueId("2"))
|
||||
private val voice3 = aMediaItemVoice(id = UniqueId("3"))
|
||||
private val image1 = aMediaItemImage(id = UniqueId("1"))
|
||||
private val image2 = aMediaItemImage(id = UniqueId("2"))
|
||||
private val image3 = aMediaItemImage(id = UniqueId("3"))
|
||||
|
|
@ -163,6 +167,9 @@ class MediaItemsPostProcessorTest {
|
|||
fun `process will handle complex case`() {
|
||||
test(
|
||||
mediaItems = listOf(
|
||||
voice3,
|
||||
voice2,
|
||||
voice1,
|
||||
audio3,
|
||||
audio2,
|
||||
audio1,
|
||||
|
|
@ -192,6 +199,9 @@ class MediaItemsPostProcessorTest {
|
|||
audio1,
|
||||
audio2,
|
||||
audio3,
|
||||
voice1,
|
||||
voice2,
|
||||
voice3,
|
||||
date3,
|
||||
file3,
|
||||
loading1,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue