Media: address PR review
This commit is contained in:
parent
b72291a91d
commit
865d521545
7 changed files with 120 additions and 102 deletions
|
|
@ -224,6 +224,7 @@ koverMerged {
|
||||||
excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
|
excludes += "io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*"
|
||||||
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
|
excludes += "io.element.android.libraries.push.impl.notifications.NotificationState*"
|
||||||
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
excludes += "io.element.android.features.messages.impl.media.local.pdf.PdfViewerState"
|
||||||
|
excludes += "io.element.android.features.messages.impl.media.local.LocalMediaViewState"
|
||||||
}
|
}
|
||||||
bound {
|
bound {
|
||||||
minValue = 90
|
minValue = 90
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,18 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.media.helper
|
package io.element.android.features.messages.impl.media.helper
|
||||||
|
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
|
||||||
fun formatFileExtensionAndSize(name: String, size: String?): String {
|
fun formatFileExtensionAndSize(name: String, size: String?): String {
|
||||||
val fileExtension = name.substringAfterLast('.', "").uppercase()
|
val fileExtension = name.substringAfterLast('.', "")
|
||||||
|
// Makes sure the extension is known by the system, otherwise default to binary extension.
|
||||||
|
val safeExtension = if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
|
||||||
|
fileExtension.uppercase()
|
||||||
|
} else {
|
||||||
|
"BIN"
|
||||||
|
}
|
||||||
return buildString {
|
return buildString {
|
||||||
append(fileExtension)
|
append(safeExtension)
|
||||||
if (size != null) {
|
if (size != null) {
|
||||||
append(' ')
|
append(' ')
|
||||||
append("($size)")
|
append("($size)")
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,11 @@ import androidx.compose.material.icons.outlined.Attachment
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -70,40 +74,52 @@ import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class LocalMediaViewState {
|
||||||
|
var isReady: Boolean by mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberLocalMediaViewState(): LocalMediaViewState {
|
||||||
|
return remember {
|
||||||
|
LocalMediaViewState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeOptInUsageError")
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
@Composable
|
@Composable
|
||||||
fun LocalMediaView(
|
fun LocalMediaView(
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
info: MediaInfo? = localMedia?.info,
|
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
|
||||||
onReady: () -> Unit = {},
|
mediaInfo: MediaInfo? = localMedia?.info,
|
||||||
) {
|
) {
|
||||||
val zoomableState = rememberZoomableState(
|
val zoomableState = rememberZoomableState(
|
||||||
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
||||||
)
|
)
|
||||||
val mimeType = info?.mimeType
|
val mimeType = mediaInfo?.mimeType
|
||||||
when {
|
when {
|
||||||
mimeType.isMimeTypeImage() -> MediaImageView(
|
mimeType.isMimeTypeImage() -> MediaImageView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
zoomableState = zoomableState,
|
zoomableState = zoomableState,
|
||||||
onReady = onReady,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
onReady = onReady,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
zoomableState = zoomableState,
|
zoomableState = zoomableState,
|
||||||
onReady = onReady,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
else -> MediaFileView(
|
else -> MediaFileView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
uri = localMedia?.uri,
|
uri = localMedia?.uri,
|
||||||
info = info,
|
info = mediaInfo,
|
||||||
onReady = onReady,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -111,9 +127,9 @@ fun LocalMediaView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaImageView(
|
private fun MediaImageView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
zoomableState: ZoomableState,
|
zoomableState: ZoomableState,
|
||||||
onReady: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (LocalInspectionMode.current) {
|
if (LocalInspectionMode.current) {
|
||||||
|
|
@ -124,11 +140,7 @@ private fun MediaImageView(
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
||||||
LaunchedEffect(zoomableImageState.isImageDisplayed) {
|
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
|
||||||
if (zoomableImageState.isImageDisplayed) {
|
|
||||||
onReady()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ZoomableAsyncImage(
|
ZoomableAsyncImage(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
state = zoomableImageState,
|
state = zoomableImageState,
|
||||||
|
|
@ -142,14 +154,14 @@ private fun MediaImageView(
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaVideoView(
|
fun MediaVideoView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
onReady: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val playerListener = object : Player.Listener {
|
val playerListener = object : Player.Listener {
|
||||||
override fun onRenderedFirstFrame() {
|
override fun onRenderedFirstFrame() {
|
||||||
onReady()
|
localMediaViewState.isReady = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val exoPlayer = remember {
|
val exoPlayer = remember {
|
||||||
|
|
@ -196,35 +208,27 @@ fun MediaVideoView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaPDFView(
|
fun MediaPDFView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
zoomableState: ZoomableState,
|
zoomableState: ZoomableState,
|
||||||
onReady: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val pdfViewerState = rememberPdfViewerState(
|
val pdfViewerState = rememberPdfViewerState(
|
||||||
model = localMedia?.uri,
|
model = localMedia?.uri,
|
||||||
zoomableState = zoomableState
|
zoomableState = zoomableState
|
||||||
)
|
)
|
||||||
LaunchedEffect(pdfViewerState.isLoaded) {
|
localMediaViewState.isReady = pdfViewerState.isLoaded
|
||||||
if (pdfViewerState.isLoaded) {
|
|
||||||
onReady()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
|
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaFileView(
|
fun MediaFileView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
uri: Uri?,
|
uri: Uri?,
|
||||||
info: MediaInfo?,
|
info: MediaInfo?,
|
||||||
onReady: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(Unit) {
|
localMediaViewState.isReady = uri != null
|
||||||
if(uri != null) {
|
|
||||||
onReady()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -236,26 +240,29 @@ fun MediaFileView(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Outlined.Attachment,
|
imageVector = Icons.Outlined.Attachment,
|
||||||
contentDescription = "OpenFile",
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.background,
|
tint = MaterialTheme.colorScheme.background,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.rotate(-45f),
|
.rotate(-45f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if(info == null) return
|
if (info != null) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = info.name,
|
text = info.name,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = formatFileExtensionAndSize(info.name, info.formattedFileSize),
|
text = formatFileExtensionAndSize(info.name, info.formattedFileSize),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
)
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,46 +116,37 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
|
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
|
||||||
when (localMedia) {
|
if (localMedia is Async.Success) {
|
||||||
is Async.Success -> {
|
localMediaActions.saveOnDisk(localMedia.state)
|
||||||
localMediaActions.saveOnDisk(localMedia.state)
|
.onSuccess {
|
||||||
.onSuccess {
|
val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android)
|
||||||
val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android)
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
snackbarDispatcher.post(snackbarMessage)
|
}
|
||||||
}
|
.onFailure {
|
||||||
.onFailure {
|
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||||
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
snackbarDispatcher.post(snackbarMessage)
|
}
|
||||||
}
|
} else Unit
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
|
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
|
||||||
when (localMedia) {
|
if (localMedia is Async.Success) {
|
||||||
is Async.Success -> {
|
localMediaActions.share(localMedia.state)
|
||||||
localMediaActions.share(localMedia.state)
|
.onFailure {
|
||||||
.onFailure {
|
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||||
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
snackbarDispatcher.post(snackbarMessage)
|
}
|
||||||
}
|
} else Unit
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
|
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
|
||||||
when (localMedia) {
|
if (localMedia is Async.Success) {
|
||||||
is Async.Success -> {
|
localMediaActions.open(localMedia.state)
|
||||||
localMediaActions.open(localMedia.state)
|
.onFailure {
|
||||||
.onFailure {
|
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
||||||
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
|
snackbarDispatcher.post(snackbarMessage)
|
||||||
snackbarDispatcher.post(snackbarMessage)
|
}
|
||||||
}
|
} else Unit
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mediaActionsError(throwable: Throwable): Int {
|
private fun mediaActionsError(throwable: Throwable): Int {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||||
),
|
),
|
||||||
aPdfInfo(),
|
aPdfInfo(),
|
||||||
),
|
),
|
||||||
|
aMediaViewerState(
|
||||||
|
Async.Loading(),
|
||||||
|
aFileInfo(),
|
||||||
|
),
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
Async.Success(
|
Async.Success(
|
||||||
LocalMedia(Uri.EMPTY, aFileInfo())
|
LocalMedia(Uri.EMPTY, aFileInfo())
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,15 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import io.element.android.features.messages.impl.media.local.LocalMedia
|
||||||
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
import io.element.android.features.messages.impl.media.local.LocalMediaView
|
||||||
|
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.architecture.isLoading
|
import io.element.android.libraries.architecture.isLoading
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
|
@ -82,28 +85,9 @@ fun MediaViewerView(
|
||||||
state.eventSink(MediaViewerEvents.ClearLoadingError)
|
state.eventSink(MediaViewerEvents.ClearLoadingError)
|
||||||
}
|
}
|
||||||
|
|
||||||
var showProgress by remember {
|
val localMediaViewState = rememberLocalMediaViewState()
|
||||||
mutableStateOf(false)
|
val showThumbnail = !localMediaViewState.isReady
|
||||||
}
|
val showProgress = rememberShowProgress(state.downloadedMedia)
|
||||||
|
|
||||||
// Trick to avoid showing progress indicator if the media is already on disk.
|
|
||||||
// When sdk will expose download progress we'll be able to remove this.
|
|
||||||
LaunchedEffect(state.downloadedMedia) {
|
|
||||||
showProgress = false
|
|
||||||
delay(100)
|
|
||||||
if (state.downloadedMedia.isLoading()) {
|
|
||||||
showProgress = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var showThumbnail by remember {
|
|
||||||
mutableStateOf(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onMediaReady() {
|
|
||||||
showThumbnail = false
|
|
||||||
}
|
|
||||||
|
|
||||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -151,9 +135,9 @@ fun MediaViewerView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
LocalMediaView(
|
LocalMediaView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = state.downloadedMedia.dataOrNull(),
|
localMedia = state.downloadedMedia.dataOrNull(),
|
||||||
info = state.mediaInfo,
|
mediaInfo = state.mediaInfo,
|
||||||
onReady = ::onMediaReady
|
|
||||||
)
|
)
|
||||||
ThumbnailView(
|
ThumbnailView(
|
||||||
thumbnailSource = state.thumbnailSource,
|
thumbnailSource = state.thumbnailSource,
|
||||||
|
|
@ -164,6 +148,27 @@ fun MediaViewerView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberShowProgress(downloadedMedia: Async<LocalMedia>): Boolean {
|
||||||
|
var showProgress by remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
showProgress = downloadedMedia.isLoading()
|
||||||
|
} else {
|
||||||
|
// Trick to avoid showing progress indicator if the media is already on disk.
|
||||||
|
// When sdk will expose download progress we'll be able to remove this.
|
||||||
|
LaunchedEffect(downloadedMedia) {
|
||||||
|
showProgress = false
|
||||||
|
delay(100)
|
||||||
|
if (downloadedMedia.isLoading()) {
|
||||||
|
showProgress = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return showProgress
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaViewerTopBar(
|
private fun MediaViewerTopBar(
|
||||||
actionsEnabled: Boolean,
|
actionsEnabled: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ fun TimelineItemFileView(
|
||||||
text = content.fileExtensionAndSize,
|
text = content.fileExtensionAndSize,
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue