Merge pull request #2438 from element-hq/feature/fga/improve_media_viewer

[Improvement] MediaViewer/Attachment experience
This commit is contained in:
ganfra 2024-02-26 10:39:02 +01:00 committed by GitHub
commit 2c8c43f5d3
22 changed files with 330 additions and 138 deletions

1
changelog.d/2390.feature Normal file
View file

@ -0,0 +1 @@
MediaViewer : introduce fullscreen and flick to dismiss behavior.

View file

@ -16,11 +16,12 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -28,6 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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
@ -42,7 +44,10 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
@Composable @Composable
fun AttachmentsPreviewView( fun AttachmentsPreviewView(
@ -66,16 +71,11 @@ fun AttachmentsPreviewView(
} }
Scaffold(modifier) { Scaffold(modifier) {
Box( AttachmentPreviewContent(
modifier = Modifier.padding(it), attachment = state.attachment,
contentAlignment = Alignment.Center onSendClicked = ::postSendAttachment,
) { onDismiss = onDismiss
AttachmentPreviewContent( )
attachment = state.attachment,
onSendClicked = ::postSendAttachment,
onDismiss = onDismiss
)
}
} }
AttachmentSendStateView( AttachmentSendStateView(
sendActionState = state.sendActionState, sendActionState = state.sendActionState,
@ -119,21 +119,30 @@ private fun AttachmentPreviewContent(
onSendClicked: () -> Unit, onSendClicked: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = 24.dp) .navigationBarsPadding(),
contentAlignment = Alignment.BottomCenter
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxWidth() contentAlignment = Alignment.Center
.weight(1f),
contentAlignment = Alignment.Center,
) { ) {
when (attachment) { when (attachment) {
is Attachment.Media -> LocalMediaView( is Attachment.Media -> {
localMedia = attachment.localMedia val localMediaViewState = rememberLocalMediaViewState(
) zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
)
)
LocalMediaView(
modifier = Modifier.fillMaxSize(),
localMedia = attachment.localMedia,
localMediaViewState = localMediaViewState,
onClick = {}
)
}
} }
} }
AttachmentsPreviewBottomActions( AttachmentsPreviewBottomActions(
@ -141,8 +150,9 @@ private fun AttachmentPreviewContent(
onSendClicked = onSendClicked, onSendClicked = onSendClicked,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.defaultMinSize(minHeight = 120.dp) .background(Color.Black.copy(alpha = 0.7f))
.padding(all = 24.dp) .padding(horizontal = 24.dp)
.defaultMinSize(minHeight = 80.dp)
) )
} }
} }
@ -153,9 +163,7 @@ private fun AttachmentsPreviewBottomActions(
onSendClicked: () -> Unit, onSendClicked: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
ButtonRowMolecule( ButtonRowMolecule(modifier = modifier) {
modifier = modifier,
) {
TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClicked) TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClicked)
TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClicked) TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClicked)
} }

View file

@ -39,6 +39,7 @@ showkase = "1.0.2"
appyx = "1.4.0" appyx = "1.4.0"
sqldelight = "2.0.1" sqldelight = "2.0.1"
wysiwyg = "2.29.0" wysiwyg = "2.29.0"
telephoto = "0.8.0"
# DI # DI
dagger = "2.50" dagger = "2.50"
@ -163,7 +164,8 @@ sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
vanniktech_blurhash = "com.vanniktech:blurhash:0.2.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.2.0"
telephoto_zoomableimage = "me.saket.telephoto:zoomable-image-coil:0.8.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.1" statemachine = "com.freeletics.flowredux:compose:1.2.1"
maplibre = "org.maplibre.gl:android-sdk:10.2.0" maplibre = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2"

View file

@ -39,6 +39,7 @@ dependencies {
implementation(libs.dagger) implementation(libs.dagger)
implementation(libs.telephoto.zoomableimage) implementation(libs.telephoto.zoomableimage)
implementation(libs.vanniktech.blurhash) implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.flick)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)

View file

@ -18,14 +18,16 @@ package io.element.android.libraries.mediaviewer.api.local
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -35,7 +37,10 @@ import androidx.compose.material.icons.outlined.GraphicEq
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.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
@ -72,48 +77,45 @@ import io.element.android.libraries.mediaviewer.api.local.exoplayer.ExoPlayerWra
import io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewer import io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewer
import io.element.android.libraries.mediaviewer.api.local.pdf.rememberPdfViewerState import io.element.android.libraries.mediaviewer.api.local.pdf.rememberPdfViewerState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage 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
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
fun LocalMediaView( fun LocalMediaView(
localMedia: LocalMedia?, localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info, mediaInfo: MediaInfo? = localMedia?.info,
) { ) {
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
)
val mimeType = mediaInfo?.mimeType val mimeType = mediaInfo?.mimeType
when { when {
mimeType.isMimeTypeImage() -> MediaImageView( mimeType.isMimeTypeImage() -> MediaImageView(
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
localMedia = localMedia, localMedia = localMedia,
zoomableState = zoomableState, modifier = modifier,
modifier = modifier onClick = onClick,
) )
mimeType.isMimeTypeVideo() -> MediaVideoView( mimeType.isMimeTypeVideo() -> MediaVideoView(
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
localMedia = localMedia, localMedia = localMedia,
modifier = modifier modifier = modifier,
onClick = onClick,
) )
mimeType == MimeTypes.Pdf -> MediaPDFView( mimeType == MimeTypes.Pdf -> MediaPDFView(
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
localMedia = localMedia, localMedia = localMedia,
zoomableState = zoomableState, modifier = modifier,
modifier = modifier onClick = onClick,
) )
// TODO handle audio with exoplayer // TODO handle audio with exoplayer
else -> MediaFileView( else -> MediaFileView(
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
uri = localMedia?.uri, uri = localMedia?.uri,
info = mediaInfo, info = mediaInfo,
modifier = modifier modifier = modifier,
onClick = onClick,
) )
} }
} }
@ -122,24 +124,25 @@ fun LocalMediaView(
private fun MediaImageView( private fun MediaImageView(
localMediaViewState: LocalMediaViewState, localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?, localMedia: LocalMedia?,
zoomableState: ZoomableState, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (LocalInspectionMode.current) { if (LocalInspectionMode.current) {
Image( Image(
painter = painterResource(id = CommonDrawables.sample_background), painter = painterResource(id = CommonDrawables.sample_background),
modifier = modifier.fillMaxSize(), modifier = modifier,
contentDescription = null, contentDescription = null,
) )
} else { } else {
val zoomableImageState = rememberZoomableImageState(zoomableState) val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState)
localMediaViewState.isReady = zoomableImageState.isImageDisplayed localMediaViewState.isReady = zoomableImageState.isImageDisplayed
ZoomableAsyncImage( ZoomableAsyncImage(
modifier = modifier.fillMaxSize(), modifier = modifier,
state = zoomableImageState, state = zoomableImageState,
model = localMedia?.uri, model = localMedia?.uri,
contentDescription = stringResource(id = CommonStrings.common_image), contentDescription = stringResource(id = CommonStrings.common_image),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
onClick = { onClick() }
) )
} }
} }
@ -149,8 +152,14 @@ private fun MediaImageView(
private fun MediaVideoView( private fun MediaVideoView(
localMediaViewState: LocalMediaViewState, localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?, localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var playableState: PlayableState.Playable by remember {
mutableStateOf(PlayableState.Playable(isPlaying = false, isShowingControls = false))
}
localMediaViewState.playableState = playableState
val context = LocalContext.current val context = LocalContext.current
val playerListener = object : Player.Listener { val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() { override fun onRenderedFirstFrame() {
@ -158,7 +167,7 @@ private fun MediaVideoView(
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
localMediaViewState.isPlaying = isPlaying playableState = playableState.copy(isPlaying = isPlaying)
} }
} }
val exoPlayer = remember { val exoPlayer = remember {
@ -176,19 +185,34 @@ private fun MediaVideoView(
} else { } else {
exoPlayer.setMediaItems(emptyList()) exoPlayer.setMediaItems(emptyList())
} }
KeepScreenOn(localMediaViewState.isPlaying) KeepScreenOn(playableState.isPlaying)
AndroidView( AndroidView(
factory = { factory = {
PlayerView(context).apply { PlayerView(context).apply {
player = exoPlayer player = exoPlayer
setShowPreviousButton(false)
setShowNextButton(false)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
controllerShowTimeoutMs = 3000 setOnClickListener {
onClick()
}
setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility ->
val isShowingControls = visibility == View.VISIBLE
playableState = playableState.copy(isShowingControls = isShowingControls)
})
controllerShowTimeoutMs = 1500
setShowPreviousButton(false)
setShowFastForwardButton(false)
setShowRewindButton(false)
setShowNextButton(false)
showController()
} }
}, },
modifier = modifier.fillMaxSize() onRelease = { playerView ->
playerView.setOnClickListener(null)
playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?)
playerView.player = null
},
modifier = modifier
) )
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
@ -208,15 +232,19 @@ private fun MediaVideoView(
private fun MediaPDFView( private fun MediaPDFView(
localMediaViewState: LocalMediaViewState, localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?, localMedia: LocalMedia?,
zoomableState: ZoomableState, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pdfViewerState = rememberPdfViewerState( val pdfViewerState = rememberPdfViewerState(
model = localMedia?.uri, model = localMedia?.uri,
zoomableState = zoomableState zoomableState = localMediaViewState.zoomableState,
) )
localMediaViewState.isReady = pdfViewerState.isLoaded localMediaViewState.isReady = pdfViewerState.isLoaded
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier) PdfViewer(
pdfViewerState = pdfViewerState,
onClick = onClick,
modifier = modifier,
)
} }
@Composable @Composable
@ -224,11 +252,23 @@ private fun MediaFileView(
localMediaViewState: LocalMediaViewState, localMediaViewState: LocalMediaViewState,
uri: Uri?, uri: Uri?,
info: MediaInfo?, info: MediaInfo?,
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse() val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null localMediaViewState.isReady = uri != null
Box(modifier = modifier.padding(horizontal = 8.dp), contentAlignment = Alignment.Center) {
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.padding(horizontal = 8.dp)
.clickable(
onClick = onClick,
interactionSource = interactionSource,
indication = null
),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box( Box(
modifier = Modifier modifier = Modifier

View file

@ -17,21 +17,35 @@
package io.element.android.libraries.mediaviewer.api.local package io.element.android.libraries.mediaviewer.api.local
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.rememberZoomableState
@Stable @Stable
class LocalMediaViewState { class LocalMediaViewState internal constructor(
val zoomableState: ZoomableState,
) {
var isReady: Boolean by mutableStateOf(false) var isReady: Boolean by mutableStateOf(false)
var isPlaying: Boolean by mutableStateOf(false) var playableState: PlayableState by mutableStateOf(PlayableState.NotPlayable)
}
@Immutable
sealed interface PlayableState {
data object NotPlayable : PlayableState
data class Playable(
val isPlaying: Boolean,
val isShowingControls: Boolean
) : PlayableState
} }
@Composable @Composable
fun rememberLocalMediaViewState(): LocalMediaViewState { fun rememberLocalMediaViewState(zoomableState: ZoomableState = rememberZoomableState()): LocalMediaViewState {
return remember { return remember(zoomableState) {
LocalMediaViewState() LocalMediaViewState(zoomableState)
} }
} }

View file

@ -21,6 +21,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -47,10 +48,15 @@ import me.saket.telephoto.zoomable.zoomable
@Composable @Composable
fun PdfViewer( fun PdfViewer(
pdfViewerState: PdfViewerState, pdfViewerState: PdfViewerState,
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BoxWithConstraints( BoxWithConstraints(
modifier = modifier.zoomable(pdfViewerState.zoomableState), modifier = modifier
.zoomable(
state = pdfViewerState.zoomableState,
onClick = { onClick() }
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val maxWidthInPx = maxWidth.roundToPx() val maxWidthInPx = maxWidth.roundToPx()
@ -61,7 +67,10 @@ fun PdfViewer(
} }
} }
val pdfPages = pdfViewerState.getPages() val pdfPages = pdfViewerState.getPages()
PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState) PdfPagesView(
pdfPages = pdfPages.toImmutableList(),
lazyListState = pdfViewerState.lazyListState,
)
} }
} }
@ -74,8 +83,12 @@ private fun PdfPagesView(
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
state = lazyListState, state = lazyListState,
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically) verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
) { ) {
// Add a fake item to the top so that the first item is not at the top of the screen.
item {
Spacer(modifier = Modifier.height(80.dp))
}
items(pdfPages.size) { index -> items(pdfPages.size) { index ->
val pdfPage = pdfPages[index] val pdfPage = pdfPages[index]
PdfPageView(pdfPage) PdfPageView(pdfPage)

View file

@ -19,34 +19,36 @@
package io.element.android.libraries.mediaviewer.api.viewer package io.element.android.libraries.mediaviewer.api.viewer
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue 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.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode 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 io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
@ -65,15 +67,64 @@ import io.element.android.libraries.mediaviewer.api.R
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
import io.element.android.libraries.mediaviewer.api.local.MediaInfo import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.PlayableState
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.saket.telephoto.flick.FlickToDismiss
import me.saket.telephoto.flick.FlickToDismissState
import me.saket.telephoto.flick.rememberFlickToDismissState
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
import me.saket.telephoto.zoomable.rememberZoomableState
import kotlin.time.Duration
@Composable @Composable
fun MediaViewerView( fun MediaViewerView(
state: MediaViewerState, state: MediaViewerState,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
Scaffold(
modifier,
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
MediaViewerPage(
showOverlay = showOverlay,
state = state,
onDismiss = {
onBackPressed()
},
onShowOverlayChanged = {
showOverlay = it
}
)
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
MediaViewerTopBar(
actionsEnabled = state.downloadedMedia is AsyncData.Success,
mimeType = state.mediaInfo.mimeType,
onBackPressed = onBackPressed,
canDownload = state.canDownload,
canShare = state.canShare,
eventSink = state.eventSink
)
}
}
}
@Composable
private fun MediaViewerPage(
showOverlay: Boolean,
state: MediaViewerState,
onDismiss: () -> Unit,
onShowOverlayChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) { ) {
fun onRetry() { fun onRetry() {
state.eventSink(MediaViewerEvents.RetryLoading) state.eventSink(MediaViewerEvents.RetryLoading)
@ -83,65 +134,107 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvents.ClearLoadingError) state.eventSink(MediaViewerEvents.ClearLoadingError)
} }
val localMediaViewState = rememberLocalMediaViewState() val currentShowOverlay by rememberUpdatedState(showOverlay)
val showThumbnail = !localMediaViewState.isReady val currentOnShowOverlayChanged by rememberUpdatedState(onShowOverlayChanged)
val showProgress = rememberShowProgress(state.downloadedMedia) val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold( DismissFlickEffects(
modifier, flickState = flickState,
topBar = { onDismissing = { animationDuration ->
MediaViewerTopBar( delay(animationDuration / 3)
actionsEnabled = state.downloadedMedia is AsyncData.Success, onDismiss()
mimeType = state.mediaInfo.mimeType,
onBackPressed = onBackPressed,
canDownload = state.canDownload,
canShare = state.canShare,
eventSink = state.eventSink
)
}, },
snackbarHost = { SnackbarHost(snackbarHostState) }, onDragging = {
currentOnShowOverlayChanged(false)
}
)
FlickToDismiss(
state = flickState,
modifier = modifier.background(backgroundColorFor(flickState))
) { ) {
Column( val showProgress = rememberShowProgress(state.downloadedMedia)
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(it), .navigationBarsPadding()
) { ) {
if (showProgress) { Box(contentAlignment = Alignment.Center) {
LinearProgressIndicator( val zoomableState = rememberZoomableState(
Modifier zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
.fillMaxWidth()
.height(2.dp)
) )
} else { val localMediaViewState = rememberLocalMediaViewState(zoomableState)
Spacer(Modifier.height(2.dp)) val showThumbnail = !localMediaViewState.isReady
} val playableState = localMediaViewState.playableState
Box( val showError = state.downloadedMedia is AsyncData.Failure
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center LaunchedEffect(playableState) {
) { if (playableState is PlayableState.Playable) {
if (state.downloadedMedia is AsyncData.Failure) { currentOnShowOverlayChanged(playableState.isShowingControls)
}
}
LocalMediaView(
modifier = Modifier.fillMaxSize(),
localMediaViewState = localMediaViewState,
localMedia = state.downloadedMedia.dataOrNull(),
mediaInfo = state.mediaInfo,
onClick = {
if (playableState is PlayableState.NotPlayable) {
currentOnShowOverlayChanged(!currentShowOverlay)
}
},
)
ThumbnailView(
mediaInfo = state.mediaInfo,
thumbnailSource = state.thumbnailSource,
isVisible = showThumbnail,
zoomableState = zoomableState
)
if (showError) {
ErrorView( ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown), errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry, onRetry = ::onRetry,
onDismiss = ::onDismissError onDismiss = ::onDismissError
) )
} }
LocalMediaView( }
localMediaViewState = localMediaViewState, if (showProgress) {
localMedia = state.downloadedMedia.dataOrNull(), LinearProgressIndicator(
mediaInfo = state.mediaInfo, modifier = Modifier
) .fillMaxWidth()
ThumbnailView( .height(2.dp)
mediaInfo = state.mediaInfo,
thumbnailSource = state.thumbnailSource,
showThumbnail = showThumbnail,
) )
} }
} }
} }
} }
@Composable
private fun DismissFlickEffects(
flickState: FlickToDismissState,
onDismissing: suspend (Duration) -> Unit,
onDragging: suspend () -> Unit,
) {
val currentOnDismissing by rememberUpdatedState(onDismissing)
val currentOnDragging by rememberUpdatedState(onDragging)
when (val gestureState = flickState.gestureState) {
is FlickToDismissState.GestureState.Dismissing -> {
LaunchedEffect(Unit) {
currentOnDismissing(gestureState.animationDuration)
}
}
is FlickToDismissState.GestureState.Dragging -> {
LaunchedEffect(Unit) {
currentOnDragging()
}
}
else -> Unit
}
}
@Composable @Composable
private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolean { private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolean {
var showProgress by remember { var showProgress by remember {
@ -175,6 +268,9 @@ private fun MediaViewerTopBar(
) { ) {
TopAppBar( TopAppBar(
title = {}, title = {},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent.copy(0.6f),
),
navigationIcon = { BackButton(onClick = onBackPressed) }, navigationIcon = { BackButton(onClick = onBackPressed) },
actions = { actions = {
IconButton( IconButton(
@ -227,26 +323,28 @@ private fun MediaViewerTopBar(
@Composable @Composable
private fun ThumbnailView( private fun ThumbnailView(
thumbnailSource: MediaSource?, thumbnailSource: MediaSource?,
showThumbnail: Boolean, isVisible: Boolean,
mediaInfo: MediaInfo, mediaInfo: MediaInfo,
zoomableState: ZoomableState,
modifier: Modifier = Modifier,
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = showThumbnail, visible = isVisible,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val mediaRequestData = MediaRequestData( val mediaRequestData = MediaRequestData(
source = thumbnailSource, source = thumbnailSource,
kind = MediaRequestData.Kind.File(mediaInfo.name, mediaInfo.mimeType) kind = MediaRequestData.Kind.File(mediaInfo.name, mediaInfo.mimeType)
) )
AsyncImage( ZoomableAsyncImage(
state = rememberZoomableImageState(zoomableState),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
model = mediaRequestData, model = mediaRequestData,
alpha = 0.8f,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
contentDescription = null, contentDescription = null,
) )
@ -267,6 +365,21 @@ private fun ErrorView(
) )
} }
@Composable
private fun backgroundColorFor(flickState: FlickToDismissState): Color {
val animatedAlpha by animateFloatAsState(
targetValue = when (flickState.gestureState) {
is FlickToDismissState.GestureState.Dismissed,
is FlickToDismissState.GestureState.Dismissing -> 0f
is FlickToDismissState.GestureState.Dragging,
is FlickToDismissState.GestureState.Idle,
is FlickToDismissState.GestureState.Resetting -> 1f - flickState.offsetFraction
},
label = "Background alpha",
)
return Color.Black.copy(alpha = animatedAlpha)
}
// Only preview in dark, dark theme is forced on the Node. // Only preview in dark, dark theme is forced on the Node.
@Preview @Preview
@Composable @Composable

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3008d0953bbe1135796e50e1c1175c25f3e138892c7bd431444525213c9c91b8 oid sha256:5d8c8580f22a7dbf6ac546e0c741259109e039f9e2dbc1bdc48966031b4f7cdb
size 396053 size 396496

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2909eab36362e1c4ca470ef65bb6fabfe9c410e9a6666a2d14bb65f8b753c203 oid sha256:4b209f5c4f0775eeb4059fa26a0fe9468301b0b82c5bf5627a93a01cb7a3e6d8
size 16237 size 16609

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:09d54a1201ffbbfdc0f63eaf075f66c2c446b6b773f8929226a2455118710de7 oid sha256:28a52e7e011a59fbeeaf2477d4fc15f3ea8f615906d0dbf3642a3e7473d32cbc
size 64620 size 49465

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5f50bf3f0c23362762b9911352179d428821da26cbf5cdfc88dd31285ae8b478 oid sha256:8507b04315e9f112e962e4aa3d572f85f5985f6dd9153b9fe5e0572500de1dfe
size 100137 size 90187

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3e380cb38a6efcf9421ad6f86fbcebe82191c4d202dc6fbfb408b0c20b9c4928 oid sha256:4454f74e8b205627df523773938a9f6f689116446a29fe3098da03c158bd1d37
size 395461 size 396004

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d53a17bf0c1e43175d282a85923d184ea5bb913f73367916121eefc74ea82d4c oid sha256:a9e99773ba190bc9e08faa644568ec576064967b9367ee0f6e324f720b454680
size 395464 size 396007

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9da218de660ad16ae8befe015703afe654dc9eef033d4edb00467ad3da4923a9 oid sha256:617afc64adfcf660ba28a9c0cc7a8089efe76b0b9d382dfc4ae9ea29e02130e0
size 394418 size 394862

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dfb06486f14c7ca54d49c69659a2861fc6c1a0b09b8bcd3e390c4126061bd83a oid sha256:e20d3b49668069aa81d457b2fd6ce6ce2e4e72f8897b55db29ae8049bba13669
size 114684 size 100033

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:52486cc2cf9a3e521916c794d41df91b35b3384a47b38d97fb33bfde8e03e630 oid sha256:5fff1c9ca69312b781cc6f61b5731e46fefdbe4aab0773ac0f82c11a1d537186
size 395696 size 396253

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a8d17201eaa89a6ac0cd503796508718d6ae515240e521acfa666a19daa2be14 oid sha256:cf994c9d5bc4845c3c949e034cc2a920085e823749e60817f75e1bfbb4d8e2ad
size 6662 size 6769

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e75209484e3ed4f8d89b82004530af59a7c8e8035ce38bcbd8a0e5d64e32016c oid sha256:5bee1fe2ec77bc2d15e3b05202c8897724c89ce774db62211ddc7a94d220146c
size 15920 size 16891

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:00491f1a53c4f05c96f019e97ef006dfa2d13f9980d4b567b14e63ffdf06a870 oid sha256:ece7a72c5f5d3c0aec96568ad0188733dfb34b5169bb57a395f28a2da070f877
size 16074 size 17121

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b3809d38f5bd32392612b943ea17113948d8215707c92f919eb7d4e147a2cc3b oid sha256:df72ca3aead93edbb4dab70efc7728894a899845c05092fe5af2c73b65d8c213
size 14428 size 15520

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:be54d8f8ea0d5e376aae0304aefe9c236b8eb681b059f7a5e35ec32d0741dba9 oid sha256:584fa976463bed507362846401b73662051dc8b8475301b0f48054f2464e9e60
size 14549 size 15685