Merge pull request #4059 from element-hq/feature/bma/mediaGalleryUpdate

Media gallery update
This commit is contained in:
Benoit Marty
2024-12-18 14:20:07 +01:00
committed by GitHub
37 changed files with 184 additions and 305 deletions

View File

@@ -104,7 +104,6 @@ class EventItemFactory @Inject constructor(
waveform = null,
),
mediaSource = type.source,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
)
is FileMessageType -> MediaItem.File(
id = currentTimelineItem.uniqueId,

View File

@@ -34,7 +34,6 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -73,7 +72,6 @@ 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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -266,44 +264,46 @@ private fun MediaGalleryFilesList(
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(files) { item ->
items(
items = files,
key = { it.id() },
contentType = { it::class.java },
) { item ->
when (item) {
is MediaItem.File -> FileItemView(
item,
modifier = Modifier.animateItem(),
file = item,
onClick = { onItemClick(item) },
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
)
is MediaItem.Audio -> AudioItemView(
item,
modifier = Modifier.animateItem(),
audio = item,
onClick = { onItemClick(item) },
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
)
is MediaItem.Voice -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
VoiceItemView(
presenter.present(),
item,
modifier = Modifier.animateItem(),
state = presenter.present(),
voice = 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(
modifier = Modifier.animateItem(),
item = item
)
is MediaItem.Image,
is MediaItem.Video -> {
// Should not happen
}
is MediaItem.LoadingIndicator -> {
LoadingMoreIndicator(item.direction)
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
}
}
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
modifier = Modifier.animateItem(),
item = item,
eventSink = eventSink,
)
}
}
}
@@ -315,28 +315,20 @@ private fun MediaGalleryImageGrid(
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val horizontalPadding = 16.dp
val itemSpacing = 4.dp
val availableWidth = screenWidth - horizontalPadding * 2
val minCellWidth = 80.dp
// Calculate the number of columns
val columns = max(1, (availableWidth / (minCellWidth + itemSpacing)).toInt())
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = horizontalPadding),
columns = GridCells.Fixed(columns),
.padding(horizontal = 16.dp),
columns = GridCells.Adaptive(80.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(
imagesAndVideos,
items = imagesAndVideos,
span = { item ->
when (item) {
is MediaItem.LoadingIndicator,
is MediaItem.DateSeparator -> GridItemSpan(columns)
is MediaItem.DateSeparator -> GridItemSpan(maxLineSpan)
is MediaItem.Event -> GridItemSpan(1)
}
},
@@ -344,9 +336,10 @@ private fun MediaGalleryImageGrid(
contentType = { it::class.java },
) { item ->
when (item) {
is MediaItem.DateSeparator -> {
DateItemView(item)
}
is MediaItem.DateSeparator -> DateItemView(
modifier = Modifier.animateItem(),
item = item,
)
is MediaItem.Audio -> {
// Should not happen
}
@@ -356,31 +349,27 @@ private fun MediaGalleryImageGrid(
is MediaItem.File -> {
// Should not happen
}
is MediaItem.Image -> {
ImageItemView(
image = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
}
is MediaItem.Video -> {
VideoItemView(
video = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
}
is MediaItem.LoadingIndicator -> {
LoadingMoreIndicator(item.direction)
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
}
}
is MediaItem.Image -> ImageItemView(
modifier = Modifier.animateItem(),
image = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.Video -> VideoItemView(
modifier = Modifier.animateItem(),
video = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
modifier = Modifier.animateItem(),
item = item,
eventSink = eventSink,
)
}
}
}
@@ -388,14 +377,15 @@ private fun MediaGalleryImageGrid(
@Composable
private fun LoadingMoreIndicator(
direction: Timeline.PaginationDirection,
item: MediaItem.LoadingIndicator,
eventSink: (MediaGalleryEvents) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
when (direction) {
when (item.direction) {
Timeline.PaginationDirection.FORWARDS -> {
LinearProgressIndicator(
modifier = Modifier
@@ -411,6 +401,10 @@ private fun LoadingMoreIndicator(
)
}
}
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
}
}
}

View File

@@ -57,7 +57,6 @@ sealed interface MediaItem {
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val duration: String?,
) : Event
data class Voice(

View File

@@ -54,12 +54,12 @@ class MediaItemsPostProcessor @Inject constructor() {
when (item) {
is MediaItem.Image,
is MediaItem.Video -> {
imageAndVideoItemsSubList.add(0, item)
imageAndVideoItemsSubList.add(item)
}
is MediaItem.Audio,
is MediaItem.Voice,
is MediaItem.File -> {
fileItemsSublist.add(0, item)
fileItemsSublist.add(item)
}
}
}

View File

@@ -9,7 +9,6 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -30,13 +29,11 @@ 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.core.extensions.withBrackets
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
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
@@ -44,31 +41,24 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
fun AudioItemView(
audio: MediaItem.Audio,
onClick: () -> Unit,
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
FilenameRow(
audio = audio,
onClick = onClick,
)
val caption = audio.mediaInfo.caption
if (caption != null) {
Spacer(modifier = Modifier.height(16.dp))
Caption(caption)
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
Spacer(modifier = Modifier.height(16.dp))
ActionIconsRow(
onShareClick = onShareClick,
onDownloadClick = onDownloadClick,
onInfoClick = onInfoClick,
)
HorizontalDivider()
}
}
@@ -101,16 +91,6 @@ private fun FilenameRow(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
)
audio.duration?.let {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = audio.duration,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = audio.mediaInfo.filename,
@@ -131,55 +111,6 @@ private fun FilenameRow(
}
}
@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 AudioItemViewPreview(
@@ -188,8 +119,5 @@ internal fun AudioItemViewPreview(
AudioItemView(
audio = audio,
onClick = {},
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
)
}

View File

@@ -0,0 +1,34 @@
/*
* 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.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun CaptionView(
caption: String,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}

View File

@@ -9,7 +9,6 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -34,7 +33,6 @@ 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
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
@@ -42,31 +40,24 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
fun FileItemView(
file: MediaItem.File,
onClick: () -> Unit,
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
FilenameRow(
file = file,
onClick = onClick,
)
val caption = file.mediaInfo.caption
if (caption != null) {
Spacer(modifier = Modifier.height(16.dp))
Caption(caption)
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
Spacer(modifier = Modifier.height(16.dp))
ActionIconsRow(
onShareClick = onShareClick,
onDownloadClick = onDownloadClick,
onInfoClick = onInfoClick,
)
HorizontalDivider()
}
}
@@ -119,55 +110,6 @@ private fun FilenameRow(
}
}
@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 FileItemViewPreview(
@@ -176,8 +118,5 @@ internal fun FileItemViewPreview(
FileItemView(
file = file,
onClick = {},
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
)
}

View File

@@ -32,7 +32,6 @@ fun aMediaItemAudio(
id: UniqueId = UniqueId("fileId"),
filename: String = "filename",
caption: String? = null,
duration: String? = "1:23",
): MediaItem.Audio {
return MediaItem.Audio(
id = id,
@@ -42,6 +41,5 @@ fun aMediaItemAudio(
caption = caption,
),
mediaSource = MediaSource(""),
duration = duration,
)
}

View File

@@ -66,18 +66,19 @@ fun VoiceItemView(
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
VoiceInfoRow(
state = state,
voice = voice,
)
val caption = voice.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(16.dp))
Caption(caption)
}
Spacer(modifier = Modifier.height(16.dp))
ActionIconsRow(
onShareClick = onShareClick,
onDownloadClick = onDownloadClick,
@@ -116,7 +117,7 @@ private fun VoiceInfoRow(
}
Spacer(Modifier.width(8.dp))
Text(
text = state.time,
text = if (state.progress > 0f) state.time else voice.duration ?: state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdMedium,
maxLines = 1,
@@ -256,18 +257,6 @@ private fun CustomIconButton(
)
}
@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,

View File

@@ -262,7 +262,6 @@ class DefaultEventItemFactoryTest {
waveform = null,
),
mediaSource = MediaSource(""),
duration = "7:36",
)
)
}

View File

@@ -85,10 +85,10 @@ class MediaItemsPostProcessorTest {
expectedImageAndVideoItems = emptyList(),
expectedFileItems = listOf(
date1,
file1,
file2,
file3,
audio1,
file3,
file2,
file1,
),
)
}
@@ -104,9 +104,9 @@ class MediaItemsPostProcessorTest {
),
expectedImageAndVideoItems = listOf(
date1,
image1,
image2,
image3,
image2,
image1,
),
expectedFileItems = emptyList(),
)
@@ -124,13 +124,13 @@ class MediaItemsPostProcessorTest {
),
expectedImageAndVideoItems = listOf(
date1,
video1,
image1,
video1,
),
expectedFileItems = listOf(
date1,
file1,
audio1,
file1,
),
)
}
@@ -167,6 +167,11 @@ class MediaItemsPostProcessorTest {
fun `process will handle complex case`() {
test(
mediaItems = listOf(
file3,
date3,
video3,
video2,
date2,
voice3,
voice2,
voice1,
@@ -177,33 +182,28 @@ class MediaItemsPostProcessorTest {
image1,
video1,
date1,
file3,
date3,
video3,
video2,
date2,
loading1,
),
expectedImageAndVideoItems = listOf(
date1,
video1,
image1,
date2,
video2,
video3,
video2,
date1,
image1,
video1,
loading1,
),
expectedFileItems = listOf(
date1,
file1,
audio1,
audio2,
audio3,
voice1,
voice2,
voice3,
date3,
file3,
date1,
voice3,
voice2,
voice1,
audio3,
audio2,
audio1,
file1,
loading1,
),
)