Merge pull request #4045 from element-hq/feature/bma/inlinePlayer

Inline voice message player in the files gallery.
This commit is contained in:
Benoit Marty
2024-12-17 14:24:54 +01:00
committed by GitHub
52 changed files with 825 additions and 102 deletions

View File

@@ -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,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {},
)
}
}

View File

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

View File

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

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

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

View File

@@ -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,
)

View File

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

View File

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

View File

@@ -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 = {},
)
}

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

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

View File

@@ -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,

View File

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

View File

@@ -224,6 +224,7 @@ Compose:
- LocalCompoundColors
- LocalSnackbarDispatcher
- LocalCameraPositionState
- LocalMediaItemPresenterFactories
- LocalTimelineItemPresenterFactories
- LocalRoomMemberProfilesCache
- LocalMentionSpanTheme