MediaViewer: iterate on design

This commit is contained in:
Benoit Marty
2024-11-29 13:50:19 +01:00
parent 46ab03e550
commit 23b5776474
11 changed files with 264 additions and 86 deletions

View File

@@ -337,7 +337,9 @@ class MessagesFlowNode @AssistedInject constructor(
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
fileExtension = event.content.fileExtension,
senderName = event.safeSenderName,
dateSent = event.sentTime,
),
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource,
@@ -355,7 +357,9 @@ class MessagesFlowNode @AssistedInject constructor(
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
fileExtension = event.content.fileExtension,
senderName = event.safeSenderName,
dateSent = event.sentTime,
),
mediaSource = event.content.preferredMediaSource,
thumbnailSource = event.content.thumbnailSource,
@@ -373,7 +377,9 @@ class MessagesFlowNode @AssistedInject constructor(
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
fileExtension = event.content.fileExtension,
senderName = event.safeSenderName,
dateSent = event.sentTime,
),
mediaSource = event.content.videoSource,
thumbnailSource = event.content.thumbnailSource,
@@ -388,7 +394,9 @@ class MessagesFlowNode @AssistedInject constructor(
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
fileExtension = event.content.fileExtension,
senderName = event.safeSenderName,
dateSent = event.sentTime,
),
mediaSource = event.content.fileSource,
thumbnailSource = event.content.thumbnailSource,
@@ -403,7 +411,9 @@ class MessagesFlowNode @AssistedInject constructor(
caption = event.content.caption,
mimeType = event.content.mimeType,
formattedFileSize = event.content.formattedFileSize,
fileExtension = event.content.fileExtension
fileExtension = event.content.fileExtension,
senderName = event.safeSenderName,
dateSent = event.sentTime,
),
mediaSource = event.content.mediaSource,
thumbnailSource = null,

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

@@ -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,
@@ -232,7 +237,8 @@ private fun ExoPlayerMediaVideoView(
},
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
.align(Alignment.BottomCenter)
.padding(bottom = bottomPaddingInPixels.toDp()),
)
}
@@ -254,6 +260,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,12 +41,15 @@ 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
@@ -51,6 +59,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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 +89,7 @@ fun MediaViewerView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
var bottomPaddingInPixels by remember { mutableIntStateOf(0) }
BackHandler { onBackClick() }
Scaffold(
modifier,
@@ -88,6 +98,7 @@ fun MediaViewerView(
) {
MediaViewerPage(
showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels,
state = state,
onDismiss = {
onBackClick()
@@ -97,14 +108,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,
onHeightChanged = { bottomPaddingInPixels = it },
eventSink = state.eventSink
)
}
}
}
}
@@ -112,6 +138,7 @@ fun MediaViewerView(
@Composable
private fun MediaViewerPage(
showOverlay: Boolean,
bottomPaddingInPixels: Int,
state: MediaViewerState,
onDismiss: () -> Unit,
onShowOverlayChange: (Boolean) -> Unit,
@@ -148,8 +175,8 @@ private fun MediaViewerPage(
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
.fillMaxSize()
.navigationBarsPadding()
) {
Box(contentAlignment = Alignment.Center) {
val zoomableState = rememberZoomableState(
@@ -168,6 +195,7 @@ private fun MediaViewerPage(
LocalMediaView(
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = bottomPaddingInPixels,
localMediaViewState = localMediaViewState,
localMedia = state.downloadedMedia.dataOrNull(),
mediaInfo = state.mediaInfo,
@@ -193,8 +221,8 @@ private fun MediaViewerPage(
if (showProgress) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.fillMaxWidth()
.height(2.dp)
)
}
}
@@ -246,23 +274,99 @@ 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?,
onHeightChanged: (Int) -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(Color(0x99101317))
.onSizeChanged {
onHeightChanged(it.height)
},
) {
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 +397,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)
}