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