Merge pull request #3979 from element-hq/feature/bma/mediaCaption

MediaViewer: iterate on design
This commit is contained in:
Benoit Marty 2024-12-03 13:03:15 +01:00 committed by GitHub
commit 71e0ff7342
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 429 additions and 227 deletions

View file

@ -40,6 +40,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.timeline.debug.EventDebugInfoNode
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentWithAttachment
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -329,100 +330,93 @@ class MessagesFlowNode @AssistedInject constructor(
}
private fun processEventClick(event: TimelineItem.Event): Boolean {
return when (event.content) {
val navTarget = when (event.content) {
is TimelineItemImageContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
true
}
is TimelineItemStickerContent -> {
/* Sticker may have an empty url and no thumbnail
if encrypted on certain bridges */
if (event.content.preferredMediaSource != null) {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = event.content.preferredMediaSource,
event.content.preferredMediaSource?.let { preferredMediaSource ->
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = preferredMediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
true
} else {
false
}
}
is TimelineItemVideoContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = event.content.videoSource,
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
true
}
is TimelineItemFileContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
mediaSource = event.content.fileSource,
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
)
overlay.show(navTarget)
true
}
is TimelineItemAudioContent -> {
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = event.content.filename,
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
),
buildMediaViewerNavTarget(
event = event,
content = event.content,
mediaSource = event.content.mediaSource,
thumbnailSource = null,
)
overlay.show(navTarget)
true
}
is TimelineItemLocationContent -> {
val navTarget = NavTarget.LocationViewer(
NavTarget.LocationViewer(
location = event.content.location,
description = event.content.description,
)
}
else -> null
}
return when (navTarget) {
is NavTarget.MediaViewer -> {
overlay.show(navTarget)
true
}
is NavTarget.LocationViewer -> {
backstack.push(navTarget)
true
}
else -> false
}
}
private fun buildMediaViewerNavTarget(
event: TimelineItem.Event,
content: TimelineItemEventContentWithAttachment,
mediaSource: MediaSource,
thumbnailSource: MediaSource?,
): NavTarget {
return NavTarget.MediaViewer(
mediaInfo = MediaInfo(
filename = content.filename,
caption = content.caption,
mimeType = content.mimeType,
formattedFileSize = content.formattedFileSize,
fileExtension = content.fileExtension,
senderName = event.safeSenderName,
dateSent = event.sentTime,
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,
)
}
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles(currentUserId = room.sessionId)

View file

@ -11,12 +11,14 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -81,8 +83,9 @@ fun AttachmentsPreviewView(
title = {},
)
}
) {
) { paddingValues ->
AttachmentPreviewContent(
modifier = Modifier.padding(paddingValues),
state = state,
localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
@ -134,14 +137,16 @@ private fun AttachmentPreviewContent(
state: AttachmentsPreviewState,
localMediaRenderer: LocalMediaRenderer,
onSendClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = Modifier
Column(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
) {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.weight(1f),
contentAlignment = Alignment.Center
) {
when (val attachment = state.attachment) {
@ -157,7 +162,6 @@ private fun AttachmentPreviewContent(
.fillMaxWidth()
.background(ElementTheme.colors.bgCanvasDefault)
.height(IntrinsicSize.Min)
.align(Alignment.BottomCenter)
.imePadding(),
)
}

View file

@ -147,7 +147,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
videoSource = messageType.source,
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
@ -186,6 +186,8 @@ class TimelineItemContentMessageFactory @Inject constructor(
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(),
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.filename)
)
}
false -> {
@ -211,7 +213,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(),
isEdited = content.isEdited,
thumbnailSource = messageType.info?.thumbnailSource,
fileSource = messageType.source,
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype ?: MimeTypes.fromFileExtension(fileExtension),
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtension

View file

@ -17,10 +17,10 @@ data class TimelineItemAudioContent(
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
override val mediaSource: MediaSource,
override val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
) : TimelineItemEventContentWithAttachment {
val fileExtensionAndSize =
formatFileExtensionAndSize(

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.media.MediaSource
@Immutable
sealed interface TimelineItemEventContent {
@ -26,6 +27,10 @@ sealed interface TimelineItemEventContentWithAttachment :
val filename: String
val caption: String?
val formattedCaption: CharSequence?
val mediaSource: MediaSource
val mimeType: String
val formattedFileSize: String
val fileExtension: String
val bestDescription: String
get() = caption ?: filename

View file

@ -15,11 +15,11 @@ data class TimelineItemFileContent(
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val fileSource: MediaSource,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
override val mimeType: String,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemFileContent"

View file

@ -31,7 +31,7 @@ fun aTimelineItemFileContent(
formattedCaption = null,
isEdited = false,
thumbnailSource = null,
fileSource = MediaSource(url = ""),
mediaSource = MediaSource(url = ""),
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",
fileExtension = "pdf"

View file

@ -18,11 +18,11 @@ data class TimelineItemImageContent(
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val mediaSource: MediaSource,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
override val mimeType: String,
val blurhash: String?,
val width: Int?,
val height: Int?,

View file

@ -14,11 +14,11 @@ data class TimelineItemStickerContent(
override val caption: String?,
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val mediaSource: MediaSource,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val formattedFileSize: String,
val fileExtension: String,
val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
override val mimeType: String,
val blurhash: String?,
val width: Int?,
val height: Int?,

View file

@ -16,7 +16,7 @@ data class TimelineItemVideoContent(
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val videoSource: MediaSource,
override val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val aspectRatio: Float?,
val blurHash: String?,
@ -24,9 +24,9 @@ data class TimelineItemVideoContent(
val width: Int?,
val thumbnailWidth: Int?,
val thumbnailHeight: Int?,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
override val mimeType: String,
override val formattedFileSize: String,
override val fileExtension: String,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemImageContent"

View file

@ -35,7 +35,7 @@ fun aTimelineItemVideoContent(
blurHash = blurhash,
aspectRatio = aspectRatio,
duration = 100.milliseconds,
videoSource = MediaSource(""),
mediaSource = MediaSource(""),
width = 150,
height = 300,
thumbnailWidth = 150,

View file

@ -19,8 +19,10 @@ data class TimelineItemVoiceContent(
override val formattedCaption: CharSequence?,
override val isEdited: Boolean,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,
override val mediaSource: MediaSource,
override val formattedFileSize: String,
override val fileExtension: String,
override val mimeType: String,
val waveform: ImmutableList<Float>,
) : TimelineItemEventContentWithAttachment {
override val type: String = "TimelineItemAudioContent"

View file

@ -53,4 +53,6 @@ fun aTimelineItemVoiceContent(
mediaSource = mediaSource,
mimeType = mimeType,
waveform = waveform.toPersistentList(),
formattedFileSize = "1.0 MB",
fileExtension = "ogg",
)

View file

@ -371,7 +371,7 @@ class MessagesPresenterTest {
formattedCaption = null,
isEdited = false,
duration = 10.milliseconds,
videoSource = MediaSource(AN_AVATAR_URL),
mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
mimeType = MimeTypes.Mp4,
blurHash = null,
@ -413,7 +413,7 @@ class MessagesPresenterTest {
caption = null,
isEdited = false,
formattedCaption = null,
fileSource = MediaSource(AN_AVATAR_URL),
mediaSource = MediaSource(AN_AVATAR_URL),
thumbnailSource = MediaSource(AN_AVATAR_URL),
formattedFileSize = "10 MB",
mimeType = MimeTypes.Pdf,

View file

@ -239,7 +239,7 @@ class TimelineItemContentMessageFactoryTest {
formattedCaption = null,
isEdited = false,
duration = Duration.ZERO,
videoSource = MediaSource(url = "url", json = null),
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
aspectRatio = null,
blurHash = null,
@ -291,7 +291,7 @@ class TimelineItemContentMessageFactoryTest {
formattedCaption = SpannedString("formatted"),
isEdited = true,
duration = 1.minutes,
videoSource = MediaSource(url = "url", json = null),
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
aspectRatio = 3f,
blurHash = A_BLUR_HASH,
@ -380,7 +380,9 @@ class TimelineItemContentMessageFactoryTest {
duration = Duration.ZERO,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.OctetStream,
waveform = emptyList<Float>().toImmutableList()
waveform = emptyList<Float>().toImmutableList(),
fileExtension = "",
formattedFileSize = "0 Bytes",
)
assertThat(result).isEqualTo(expected)
}
@ -419,7 +421,9 @@ class TimelineItemContentMessageFactoryTest {
duration = 1.minutes,
mediaSource = MediaSource(url = "url", json = null),
mimeType = MimeTypes.Ogg,
waveform = persistentListOf(1f, 2f)
waveform = persistentListOf(1f, 2f),
fileExtension = "ogg",
formattedFileSize = "123 Bytes",
)
assertThat(result).isEqualTo(expected)
}
@ -571,7 +575,7 @@ class TimelineItemContentMessageFactoryTest {
caption = null,
formattedCaption = null,
isEdited = false,
fileSource = MediaSource(url = "url", json = null),
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = null,
formattedFileSize = "0 Bytes",
fileExtension = "",
@ -612,7 +616,7 @@ class TimelineItemContentMessageFactoryTest {
caption = null,
formattedCaption = null,
isEdited = true,
fileSource = MediaSource(url = "url", json = null),
mediaSource = MediaSource(url = "url", json = null),
thumbnailSource = MediaSource("url_thumbnail"),
formattedFileSize = "123 Bytes",
fileExtension = "pdf",

View file

@ -18,44 +18,73 @@ data class MediaInfo(
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
val senderName: String?,
val dateSent: String?,
) : Parcelable
fun anImageMediaInfo(): MediaInfo = MediaInfo(
fun anImageMediaInfo(
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "an image file.jpg",
caption = null,
caption = caption,
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
senderName = senderName,
dateSent = dateSent,
)
fun aVideoMediaInfo(): MediaInfo = MediaInfo(
fun aVideoMediaInfo(
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "a video file.mp4",
caption = null,
caption = caption,
mimeType = MimeTypes.Mp4,
formattedFileSize = "14MB",
fileExtension = "mp4",
senderName = senderName,
dateSent = dateSent,
)
fun aPdfMediaInfo(): MediaInfo = MediaInfo(
fun aPdfMediaInfo(
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "a pdf file.pdf",
caption = null,
mimeType = MimeTypes.Pdf,
formattedFileSize = "23MB",
fileExtension = "pdf",
senderName = senderName,
dateSent = dateSent,
)
fun anApkMediaInfo(): MediaInfo = MediaInfo(
fun anApkMediaInfo(
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "an apk file.apk",
caption = null,
mimeType = MimeTypes.Apk,
formattedFileSize = "50MB",
fileExtension = "apk",
senderName = senderName,
dateSent = dateSent,
)
fun anAudioMediaInfo(): MediaInfo = MediaInfo(
fun anAudioMediaInfo(
senderName: String? = null,
dateSent: String? = null,
): MediaInfo = MediaInfo(
filename = "an audio file.mp3",
caption = null,
mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB",
fileExtension = "mp3",
senderName = senderName,
dateSent = dateSent,
)

View file

@ -46,7 +46,9 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = ""
fileExtension = "",
senderName = null,
dateSent = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,

View file

@ -41,6 +41,8 @@ class AndroidLocalMediaFactory @Inject constructor(
name = mediaInfo.filename,
caption = mediaInfo.caption,
formattedFileSize = mediaInfo.formattedFileSize,
senderName = mediaInfo.senderName,
dateSent = mediaInfo.dateSent,
)
override fun createFromUri(
@ -54,6 +56,8 @@ class AndroidLocalMediaFactory @Inject constructor(
name = name,
caption = null,
formattedFileSize = formattedFileSize,
senderName = null,
dateSent = null,
)
private fun createFromUri(
@ -61,7 +65,9 @@ class AndroidLocalMediaFactory @Inject constructor(
mimeType: String?,
name: String?,
caption: String?,
formattedFileSize: String?
formattedFileSize: String?,
senderName: String?,
dateSent: String?,
): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
val fileName = name ?: context.getFileName(uri) ?: ""
@ -74,7 +80,9 @@ class AndroidLocalMediaFactory @Inject constructor(
filename = fileName,
caption = caption,
formattedFileSize = fileSize,
fileExtension = fileExtension
fileExtension = fileExtension,
senderName = senderName,
dateSent = dateSent,
)
)
}

View file

@ -29,6 +29,7 @@ class DefaultLocalMediaRenderer @Inject constructor() : LocalMediaRenderer {
)
LocalMediaView(
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0,
localMedia = localMedia,
localMediaViewState = localMediaViewState,
onClick = {}

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
bottomPaddingInPixels: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
@ -37,6 +38,7 @@ fun LocalMediaView(
)
mimeType.isMimeTypeVideo() -> MediaVideoView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia,
modifier = modifier,
)

View file

@ -11,10 +11,13 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@ -22,6 +25,7 @@ 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.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@ -63,8 +67,22 @@ fun MediaPlayerControllerView(
.widthIn(max = 480.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onTogglePlay,
val bgColor = if (state.isPlaying) {
ElementTheme.colors.bgCanvasDefault
} else {
ElementTheme.colors.textPrimary
}
Box(
modifier = Modifier
.size(36.dp)
.background(
color = bgColor,
shape = CircleShape,
)
.clip(CircleShape)
.clickable { onTogglePlay() }
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
if (state.isPlaying) {
Icon(
@ -75,7 +93,7 @@ fun MediaPlayerControllerView(
} else {
Icon(
imageVector = CompoundIcons.PlaySolid(),
tint = ElementTheme.colors.iconPrimary,
tint = ElementTheme.colors.iconOnSolidPrimary,
contentDescription = stringResource(CommonStrings.a11y_play)
)
}

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@ -37,6 +38,7 @@ import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
@ -51,6 +53,7 @@ import kotlin.time.Duration.Companion.seconds
@Composable
fun MediaVideoView(
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
@ -66,6 +69,7 @@ fun MediaVideoView(
}
ExoPlayerMediaVideoView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer,
localMedia = localMedia,
modifier = modifier,
@ -76,6 +80,7 @@ fun MediaVideoView(
@Composable
private fun ExoPlayerMediaVideoView(
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer,
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
@ -229,7 +234,8 @@ private fun ExoPlayerMediaVideoView(
},
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
.align(Alignment.BottomCenter)
.padding(bottom = bottomPaddingInPixels.toDp()),
)
}
@ -252,6 +258,7 @@ private fun ExoPlayerMediaVideoView(
internal fun MediaVideoViewPreview() = ElementPreview {
MediaVideoView(
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(),
localMedia = null,
)

View file

@ -24,52 +24,72 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
aMediaViewerState(),
aMediaViewerState(AsyncData.Loading()),
aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
anImageMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, aVideoMediaInfo())
),
aVideoMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, aPdfMediaInfo())
),
aPdfMediaInfo(),
),
anImageMediaInfo(
senderName = "Sally Sanderson",
dateSent = "21 NOV, 2024",
caption = "A caption",
).let {
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
)
},
aVideoMediaInfo(
senderName = "Sally Sanderson",
dateSent = "21 NOV, 2024",
caption = "A caption",
).let {
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
)
},
aPdfMediaInfo().let {
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
)
},
aMediaViewerState(
AsyncData.Loading(),
anApkMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anApkMediaInfo())
),
anApkMediaInfo(),
),
anApkMediaInfo().let {
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
)
},
aMediaViewerState(
AsyncData.Loading(),
anAudioMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anAudioMediaInfo())
),
anAudioMediaInfo(),
),
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageMediaInfo())
),
anImageMediaInfo(),
canDownload = false,
canShare = false,
),
anAudioMediaInfo().let {
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
)
},
anImageMediaInfo().let {
aMediaViewerState(
AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
it,
canDownload = false,
canShare = false,
)
},
)
}

View file

@ -16,10 +16,14 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.ExperimentalMaterial3Api
@ -28,6 +32,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@ -36,21 +41,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
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.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
@ -80,6 +90,8 @@ fun MediaViewerView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
BackHandler { onBackClick() }
Scaffold(
modifier,
@ -88,6 +100,7 @@ fun MediaViewerView(
) {
MediaViewerPage(
showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels,
state = state,
onDismiss = {
onBackClick()
@ -97,14 +110,29 @@ fun MediaViewerView(
}
)
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
MediaViewerTopBar(
actionsEnabled = state.downloadedMedia is AsyncData.Success,
mimeType = state.mediaInfo.mimeType,
onBackClick = onBackClick,
canDownload = state.canDownload,
canShare = state.canShare,
eventSink = state.eventSink
)
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
MediaViewerTopBar(
actionsEnabled = state.downloadedMedia is AsyncData.Success,
senderName = state.mediaInfo.senderName,
dateSent = state.mediaInfo.dateSent,
onBackClick = onBackClick,
eventSink = state.eventSink
)
MediaViewerBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
actionsEnabled = state.downloadedMedia is AsyncData.Success,
canDownload = state.canDownload,
canShare = state.canShare,
mimeType = state.mediaInfo.mimeType,
caption = state.mediaInfo.caption,
onHeightChange = { bottomPaddingInPixels = it },
eventSink = state.eventSink
)
}
}
}
}
@ -112,6 +140,7 @@ fun MediaViewerView(
@Composable
private fun MediaViewerPage(
showOverlay: Boolean,
bottomPaddingInPixels: Int,
state: MediaViewerState,
onDismiss: () -> Unit,
onShowOverlayChange: (Boolean) -> Unit,
@ -148,8 +177,8 @@ private fun MediaViewerPage(
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
.fillMaxSize()
.navigationBarsPadding()
) {
Box(contentAlignment = Alignment.Center) {
val zoomableState = rememberZoomableState(
@ -168,6 +197,7 @@ private fun MediaViewerPage(
LocalMediaView(
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = bottomPaddingInPixels,
localMediaViewState = localMediaViewState,
localMedia = state.downloadedMedia.dataOrNull(),
mediaInfo = state.mediaInfo,
@ -193,8 +223,8 @@ private fun MediaViewerPage(
if (showProgress) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.fillMaxWidth()
.height(2.dp)
)
}
}
@ -246,23 +276,100 @@ private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolea
return showProgress
}
@Suppress("UNUSED_PARAMETER")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MediaViewerTopBar(
actionsEnabled: Boolean,
canDownload: Boolean,
canShare: Boolean,
mimeType: String,
senderName: String?,
dateSent: String?,
onBackClick: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
) {
TopAppBar(
title = {},
title = {
if (senderName != null && dateSent != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(end = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = senderName,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
Text(
text = dateSent,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent.copy(0.6f),
),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
// TODO Add action to open infos.
}
)
}
@Composable
private fun MediaViewerBottomBar(
actionsEnabled: Boolean,
canDownload: Boolean,
canShare: Boolean,
mimeType: String,
caption: String?,
onHeightChange: (Int) -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(Color(0x99101317))
.onSizeChanged {
onHeightChange(it.height)
},
) {
HorizontalDivider()
if (caption != null) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (canShare) {
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.Share)
},
modifier = Modifier.align(Alignment.CenterVertically)
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(id = CommonStrings.action_share)
)
}
}
Spacer(modifier = Modifier.weight(1f))
IconButton(
enabled = actionsEnabled,
onClick = {
@ -293,21 +400,8 @@ private fun MediaViewerTopBar(
)
}
}
if (canShare) {
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.Share)
},
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(id = CommonStrings.action_share)
)
}
}
}
)
}
}
@Composable

View file

@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.media.FakeMediaFile
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
@ -25,7 +26,10 @@ class AndroidLocalMediaFactoryTest {
@Test
fun `test AndroidLocalMediaFactory`() {
val sut = createAndroidLocalMediaFactory()
val result = sut.createFromMediaFile(aMediaFile(), anImageMediaInfo())
val result = sut.createFromMediaFile(aMediaFile(), anImageMediaInfo(
senderName = A_USER_NAME,
dateSent = "12:34",
))
assertThat(result.uri.toString()).endsWith("aPath")
assertThat(result.info).isEqualTo(
MediaInfo(
@ -34,6 +38,8 @@ class AndroidLocalMediaFactoryTest {
mimeType = MimeTypes.Jpeg,
formattedFileSize = "4MB",
fileExtension = "jpg",
senderName = A_USER_NAME,
dateSent = "12:34"
)
)
}

View file

@ -36,7 +36,9 @@ class FakeLocalMediaFactory(
caption = null,
mimeType = mimeType ?: fallbackMimeType,
formattedFileSize = formattedFileSize ?: fallbackFileSize,
fileExtension = fileExtensionExtractor.extractFromName(safeName)
fileExtension = fileExtensionExtractor.extractFromName(safeName),
senderName = null,
dateSent = null
)
return aLocalMedia(uri, mediaInfo)
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7839db8763b29e38ad67380e09101e48fd3a650080967fb5e51a8bf6411d0427
size 397423
oid sha256:17aa655ef22beec5cef50f5d44cb2ecd8ad3a1979bc072a33079a49a8c4495c5
size 397284

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c40163401ccd77a79d314bd373bb09d9b9bd0bd8d0afe281792c19d6fd593d3
size 51465
oid sha256:8a0e722772be6b9d38c5f0ef3198502ca6e6ebb2599e3a4ebb4cc95cde907095
size 52012

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1cba25287c6d2beee594937c3139b3800630379e1b5a552c5cca886f8646bbfe
size 51429
oid sha256:aa4cae0da657162117538b6839032ebf467542a043f18e308eeaf5ae28ca14e8
size 51984

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b4f33aa3545c29c25b3c40e6de90772e4e1f0685095429bdbcc4216e9020161c
size 89038
oid sha256:df6fc9bcb3636549fdfeacb4c9e19cc3c699b7519558bd14f409e7b0c6a84d80
size 89846

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:507c7dffae1aabfa687174f1f964e2c40b004183b6bc3a70b56d764e0d308b47
size 392923
oid sha256:aedd5d3a01457b8ed06b16cd63d08f24ae34568e2b912b82fb892b154c94e6df
size 392780

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7839db8763b29e38ad67380e09101e48fd3a650080967fb5e51a8bf6411d0427
size 397423
oid sha256:17aa655ef22beec5cef50f5d44cb2ecd8ad3a1979bc072a33079a49a8c4495c5
size 397284

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e7a7ea1da7e7602cc53cc162c4a685516a7a46ca148eafca6e19a5748630bda5
size 7036
oid sha256:333a21a41a7d5f8d47946648a7381dadeccf213a1176696b3512c08ae929d4d6
size 7819

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc35254c6962b5b113a2c25c4c9dd0c94449521152ffd73f41a0c36429fabbb0
size 7258
oid sha256:cba8f49caf65856569266a561206437840a43da2d41ed5f08321ecda99204329
size 8236

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9f69e358ddd7e00f7301598f5dd235a2f5b9077f8926c2bfa3a4bca1168900f0
size 7173
oid sha256:773785bb234c0719c0f27ee91a970c63d7f971518dc0c83298a4b6adb9acc4cf
size 8066

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b096093e04eb187fce0a85496c8a5b251afe0d0bccd977abcfb4d1e3dbe32a20
size 7436
oid sha256:5424e7ce4a5259aab297a7911741352f0fba01e5fdac972768441db9edf41171
size 7675

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3b7160dc01dd4704bb07a8c91858bef0d4e99c0f55ca1d7dbdc3e22546a25125
size 12210
oid sha256:267d4be8b727a0ecb5af1e5f1e69adfd68f50f32f1de78f4f9fde60f635244b5
size 13045

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2591b742c64d3d058d4638e5014080f8b3eb13eba3a73951d71dfdc60e7676d9
size 12455
oid sha256:412476e819f688b5314651fd3d59439fc0e4f53ee777e3a070b82fd19d51700e
size 13272

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6fe771fa6492768239d0caf7a9c10613efb95ff9c2c1b786ee0a6974392af9f
size 389635
oid sha256:9df1a0ff2b3aaab2d1a346cb134b560d65c462286575ffc6124099529562eac1
size 389594

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2b286342ff4d46637beac1f980294f77b3e2eb6824d56448cdbdce7b41c911ab
size 388612
oid sha256:4fde3cef34d23c894ae480a1b4c961065f244d845584f8607c4bb21e0a7e5f10
size 388615

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b023a77242de9cab647e87b18edcd86a48a7843aa8fa51c84b443e3e8b41bcb
size 389669
oid sha256:cf07ed00618c0552205b1e726050382369041bb7f2d9742b754a5652eca48265
size 389631

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75ff0cd0cfe594a217d2b58b48c2bb4aedad692a270c2cee8aa3d4bdf6758e1e
size 94797
oid sha256:143285221dd5a1e714a9897293f13c45ca567635e60350738033b315f44f742a
size 94735

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0dc5fdbd7d0b3980ad19bf74640067e8bb294def913e3d13d7bc4ad9c822382d
size 389921
oid sha256:a4e1b6a7a3dcc1627aec1767ac8edac95bec483ebdc98d71d65342084e08a4dc
size 396792

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:05794effa4c47e3d2b68892e613f18a55c96789a4e65a182dd0bf2ca0c812d44
size 14474
oid sha256:6c3c2b7b1d64387ec1109ab21304df50de5bd4fac0317c27a2799e7405da3843
size 22219

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e6113747e1677c2f28c2a109bc2c7075a753917068c1bbc3a46d5bdef4db4e23
size 5756
oid sha256:364fce9e921a21dcaae9ef1d7c98efe47c4c9118d23958550ee9eafaf202e2fa
size 5751

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f5a6ec7984ceab5acf44431a9bff89c29c9ac27c95804d29b800124addd6e910
size 14800
oid sha256:769a00d9e84cb2da43d790158f35e66bc5ed6538fdcb7f5e316f5de13d9bd9ad
size 14768

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d03b94e2b502b9857b9b4c2527df86d1aa0a194abed794cbe601ec4172422dcc
size 14987
oid sha256:bf1804b21f3d383c94e28714746ac61987643c8ec0480d2850b19eb01991aa4e
size 15043

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56b9d11058f06732364668a5833f52fa4e62541fa8ee48f13996ae0a11e11100
size 13609
oid sha256:ec58fa08f3c160e1b7d462b580f79b1c05a5644b9f807a79cfeebbeea417ed10
size 13502

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:25c1e3cb51b13bb65b281180862ac8fb6faa1e8bd0ce26d8d0e07321c03c116c
size 13742
oid sha256:689081427ece8bc009266a50b9d2a80def581f51f00bf65b35d295b0ea7dcab3
size 13736