Media: some more improvements over MediaViewer

This commit is contained in:
ganfra 2023-05-25 22:54:09 +02:00
parent 5e5737aa61
commit e0106fe907
16 changed files with 226 additions and 74 deletions

View file

@ -26,7 +26,6 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@ -41,8 +40,8 @@ import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.android.parcel.Parcelize
import kotlinx.collections.immutable.ImmutableList
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class MessagesFlowNode @AssistedInject constructor(
@ -65,7 +64,8 @@ class MessagesFlowNode @AssistedInject constructor(
data class MediaViewer(
val title: String,
val mediaSource: MediaSource,
val mimeType: String?
val thumbnailSource: MediaSource?,
val mimeType: String?,
) : NavTarget
@Parcelize
@ -93,7 +93,12 @@ class MessagesFlowNode @AssistedInject constructor(
createNode<MessagesNode>(buildContext, listOf(callback))
}
is NavTarget.MediaViewer -> {
val inputs = MediaViewerNode.Inputs(navTarget.title, navTarget.mediaSource, navTarget.mimeType)
val inputs = MediaViewerNode.Inputs(
name = navTarget.title,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
mimeType = navTarget.mimeType,
)
createNode<MediaViewerNode>(buildContext, listOf(inputs))
}
is NavTarget.AttachmentPreview -> {
@ -106,13 +111,22 @@ class MessagesFlowNode @AssistedInject constructor(
private fun processEventClicked(event: TimelineItem.Event) {
when (event.content) {
is TimelineItemImageContent -> {
val mediaSource = event.content.mediaSource
val navTarget = NavTarget.MediaViewer(event.content.body, mediaSource, event.content.mimeType)
val navTarget = NavTarget.MediaViewer(
title = event.content.body,
mediaSource = event.content.mediaSource,
thumbnailSource = event.content.mediaSource,
mimeType = event.content.mimeType
)
backstack.push(navTarget)
}
is TimelineItemVideoContent -> {
val mediaSource = event.content.videoSource
val navTarget = NavTarget.MediaViewer(event.content.body, mediaSource, event.content.mimeType)
val navTarget = NavTarget.MediaViewer(
title = event.content.body,
mediaSource = mediaSource,
thumbnailSource = event.content.thumbnailSource,
mimeType = event.content.mimeType,
)
backstack.push(navTarget)
}
else -> Unit
@ -124,7 +138,6 @@ class MessagesFlowNode @AssistedInject constructor(
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberBackstackFader()
)
}
}

View file

@ -18,10 +18,12 @@ package io.element.android.features.messages.impl.media.local
import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
@Immutable
data class LocalMedia(
val uri: Uri,
val mimeType: String,

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
@ -47,16 +48,20 @@ import me.saket.telephoto.zoomable.rememberZoomableState
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun LocalMediaView(
localMedia: LocalMedia,
modifier: Modifier = Modifier
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
mimeType: String? = localMedia?.mimeType,
onReady: () -> Unit = {},
) {
when {
MimeTypes.isImage(localMedia.mimeType) -> MediaImageView(
MimeTypes.isImage(mimeType) -> MediaImageView(
localMedia = localMedia,
onReady = onReady,
modifier = modifier
)
MimeTypes.isVideo(localMedia.mimeType) -> MediaVideoView(
MimeTypes.isVideo(mimeType) -> MediaVideoView(
localMedia = localMedia,
onReady = onReady,
modifier = modifier
)
else -> Unit
@ -65,7 +70,8 @@ fun LocalMediaView(
@Composable
private fun MediaImageView(
localMedia: LocalMedia,
localMedia: LocalMedia?,
onReady: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
@ -78,10 +84,16 @@ private fun MediaImageView(
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 3f)
)
val zoomableImageState = rememberZoomableImageState(zoomableState)
LaunchedEffect(zoomableImageState.isImageDisplayed) {
if (zoomableImageState.isImageDisplayed) {
onReady()
}
}
ZoomableAsyncImage(
modifier = modifier.fillMaxSize(),
state = rememberZoomableImageState(zoomableState),
model = localMedia.model,
state = zoomableImageState,
model = localMedia?.model,
contentDescription = "Image",
contentScale = ContentScale.Fit,
)
@ -91,22 +103,32 @@ private fun MediaImageView(
@UnstableApi
@Composable
fun MediaVideoView(
localMedia: LocalMedia,
localMedia: LocalMedia?,
onReady: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
onReady()
}
}
val exoPlayer = remember {
ExoPlayer.Builder(context).build()
ExoPlayer.Builder(context)
.build()
.apply {
this.playWhenReady = true
addListener(playerListener)
this.prepare()
}
}
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
}
} else {
exoPlayer.setMediaItems(emptyList())
}
AndroidView(
factory = {
PlayerView(context).apply {

View file

@ -18,4 +18,5 @@ package io.element.android.features.messages.impl.media.viewer
sealed interface MediaViewerEvents {
object RetryLoading : MediaViewerEvents
object ClearLoadingError : MediaViewerEvents
}

View file

@ -40,6 +40,7 @@ class MediaViewerNode @AssistedInject constructor(
data class Inputs(
val name: String,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val mimeType: String?
) : NodeInputs

View file

@ -67,11 +67,14 @@ class MediaViewerPresenter @AssistedInject constructor(
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
}
}
return MediaViewerState(
name = inputs.name,
mimeType = inputs.mimeType,
thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value,
eventSink = ::handleEvents
)

View file

@ -18,9 +18,12 @@ package io.element.android.features.messages.impl.media.viewer
import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.media.MediaSource
data class MediaViewerState(
val name: String,
val mimeType: String?,
val thumbnailSource: MediaSource?,
val downloadedMedia: Async<LocalMedia>,
val eventSink: (MediaViewerEvents) -> Unit
val eventSink: (MediaViewerEvents) -> Unit,
)

View file

@ -31,7 +31,14 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
aMediaViewerState(
Async.Success(
LocalMedia(
Uri.EMPTY, MimeTypes.IMAGE_JPEG, "a file", 100L
Uri.EMPTY, MimeTypes.IMAGE_JPEG, "an image file", 100L
)
),
),
aMediaViewerState(
Async.Success(
LocalMedia(
Uri.EMPTY, MimeTypes.VIDEO_MP4, "a video file", 100L
)
),
)
@ -40,6 +47,7 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
fun aMediaViewerState(downloadedMedia: Async<LocalMedia> = Async.Uninitialized) = MediaViewerState(
name = "A media",
mimeType = MimeTypes.IMAGE_JPEG,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
eventSink = {}
)
) {}

View file

@ -14,33 +14,36 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.media.viewer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
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.features.messages.impl.media.local.LocalMediaView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import kotlinx.coroutines.delay
import io.element.android.libraries.ui.strings.R as StringR
@Composable
@ -53,6 +56,32 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvents.RetryLoading)
}
fun onDismissError() {
state.eventSink(MediaViewerEvents.ClearLoadingError)
}
var showProgress by remember {
mutableStateOf(false)
}
// 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
}
Scaffold(modifier) {
Box(
modifier = Modifier
@ -60,10 +89,55 @@ fun MediaViewerView(
.padding(it),
contentAlignment = Alignment.Center
) {
when (state.downloadedMedia) {
is Async.Success -> LocalMediaView(state.downloadedMedia.state)
is Async.Failure -> ErrorView(stringResource(id = StringR.string.error_unknown), ::onRetry)
else -> CircularProgressIndicator()
if (state.downloadedMedia is Async.Failure) {
ErrorView(
errorMessage = stringResource(id = StringR.string.error_unknown),
onRetry = ::onRetry,
onDismiss = ::onDismissError
)
}
LocalMediaView(
localMedia = state.downloadedMedia.dataOrNull(),
mimeType = state.mimeType,
onReady = ::onMediaReady
)
ThumbnailView(
thumbnailSource = state.thumbnailSource,
showThumbnail = showThumbnail,
showProgress = showProgress,
)
}
}
}
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
showThumbnail: Boolean,
showProgress: Boolean,
) {
if (!showThumbnail) return
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
kind = MediaRequestData.Kind.Content
)
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = mediaRequestData,
contentScale = ContentScale.Fit,
contentDescription = null,
)
if (showProgress) {
Box(
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
@ -73,21 +147,15 @@ fun MediaViewerView(
private fun ErrorView(
errorMessage: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = errorMessage)
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = onRetry
) {
Text(text = stringResource(id = StringR.string.action_retry))
}
}
RetryDialog(
modifier = modifier,
content = errorMessage,
onRetry = onRetry,
onDismiss = onDismiss
)
}
@Preview

View file

@ -17,26 +17,22 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
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.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.timeline.components.blurhash.BlurHashAsyncImage
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContentProvider
import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@ -54,15 +50,12 @@ fun TimelineItemVideoView(
) {
BlurHashAsyncImage(
model = MediaRequestData(content.thumbnailSource, MediaRequestData.Kind.Content),
blurHash = content.blurhash,
blurHash = content.blurHash,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
Box(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.background(color = Color.Black.copy(alpha = 0.5f)),
modifier = Modifier.roundedBackground(),
contentAlignment = Alignment.Center,
) {
Image(

View file

@ -46,11 +46,11 @@ class TimelineItemContentMessageFactory @Inject constructor() {
val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height)
TimelineItemImageContent(
body = messageType.body,
height = messageType.info?.height?.toInt(),
width = messageType.info?.width?.toInt(),
mimeType = messageType.info?.mimetype,
mediaSource = messageType.source,
mimeType = messageType.info?.mimetype,
blurhash = messageType.info?.blurhash,
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
aspectRatio = aspectRatio
)
}
@ -64,7 +64,7 @@ class TimelineItemContentMessageFactory @Inject constructor() {
width = messageType.info?.width?.toInt(),
height = messageType.info?.height?.toInt(),
duration = messageType.info?.duration ?: 0L,
blurhash = messageType.info?.blurhash,
blurHash = messageType.info?.blurhash,
aspectRatio = aspectRatio
)
}

View file

@ -32,9 +32,9 @@ open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemImageContent() = TimelineItemImageContent(
body = "a body",
mediaSource = MediaSource(""),
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
aspectRatio = 0.5f,
mimeType = MimeTypes.IMAGE_JPEG,
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
width = null,
height = 300,
width = null
aspectRatio = 0.5f
)

View file

@ -24,7 +24,7 @@ data class TimelineItemVideoContent(
val videoSource: MediaSource,
val thumbnailSource: MediaSource?,
val aspectRatio: Float,
val blurhash: String?,
val blurHash: String?,
val height: Int?,
val width: Int?,
val mimeType: String?,

View file

@ -31,7 +31,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
body = "a video",
thumbnailSource = MediaSource(url = ""),
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
aspectRatio = 0.5f,
duration = 100,
videoSource = MediaSource(""),

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.modifiers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* This modifier can be use to provide a nice background for Icon or ProgressIndicator.
*/
fun Modifier.roundedBackground(
size: Dp = 48.dp,
color: Color = Color.Black,
alpha: Float = 0.5f,
) = this
.size(size)
.clip(CircleShape)
.background(color = color.copy(alpha = alpha))
.padding(8.dp)

View file

@ -23,8 +23,6 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.SpaceId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import kotlin.time.DurationUnit
import kotlin.time.toDuration
const val A_USER_NAME = "alice"
const val A_PASSWORD = "password"