Merge pull request #2438 from element-hq/feature/fga/improve_media_viewer
[Improvement] MediaViewer/Attachment experience
This commit is contained in:
commit
3213847a5b
22 changed files with 330 additions and 138 deletions
1
changelog.d/2390.feature
Normal file
1
changelog.d/2390.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
MediaViewer : introduce fullscreen and flick to dismiss behavior.
|
||||
|
|
@ -16,11 +16,12 @@
|
|||
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -28,6 +29,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.TextButton
|
||||
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 me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
|
||||
@Composable
|
||||
fun AttachmentsPreviewView(
|
||||
|
|
@ -66,16 +71,11 @@ fun AttachmentsPreviewView(
|
|||
}
|
||||
|
||||
Scaffold(modifier) {
|
||||
Box(
|
||||
modifier = Modifier.padding(it),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AttachmentPreviewContent(
|
||||
attachment = state.attachment,
|
||||
onSendClicked = ::postSendAttachment,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
AttachmentPreviewContent(
|
||||
attachment = state.attachment,
|
||||
onSendClicked = ::postSendAttachment,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
AttachmentSendStateView(
|
||||
sendActionState = state.sendActionState,
|
||||
|
|
@ -119,21 +119,30 @@ private fun AttachmentPreviewContent(
|
|||
onSendClicked: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 24.dp)
|
||||
.navigationBarsPadding(),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (attachment) {
|
||||
is Attachment.Media -> LocalMediaView(
|
||||
localMedia = attachment.localMedia
|
||||
)
|
||||
is Attachment.Media -> {
|
||||
val localMediaViewState = rememberLocalMediaViewState(
|
||||
zoomableState = rememberZoomableState(
|
||||
zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
|
||||
)
|
||||
)
|
||||
LocalMediaView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
localMedia = attachment.localMedia,
|
||||
localMediaViewState = localMediaViewState,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AttachmentsPreviewBottomActions(
|
||||
|
|
@ -141,8 +150,9 @@ private fun AttachmentPreviewContent(
|
|||
onSendClicked = onSendClicked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 120.dp)
|
||||
.padding(all = 24.dp)
|
||||
.background(Color.Black.copy(alpha = 0.7f))
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 80.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -153,9 +163,7 @@ private fun AttachmentsPreviewBottomActions(
|
|||
onSendClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ButtonRowMolecule(
|
||||
modifier = modifier,
|
||||
) {
|
||||
ButtonRowMolecule(modifier = modifier) {
|
||||
TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClicked)
|
||||
TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClicked)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ showkase = "1.0.2"
|
|||
appyx = "1.4.0"
|
||||
sqldelight = "2.0.1"
|
||||
wysiwyg = "2.29.0"
|
||||
telephoto = "0.8.0"
|
||||
|
||||
# DI
|
||||
dagger = "2.50"
|
||||
|
|
@ -163,7 +164,8 @@ sqlite = "androidx.sqlite:sqlite-ktx:2.4.0"
|
|||
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
|
||||
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
||||
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"
|
||||
maplibre = "org.maplibre.gl:android-sdk:10.2.0"
|
||||
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ dependencies {
|
|||
implementation(libs.dagger)
|
||||
implementation(libs.telephoto.zoomableimage)
|
||||
implementation(libs.vanniktech.blurhash)
|
||||
implementation(libs.telephoto.flick)
|
||||
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
|
|
|
|||
|
|
@ -18,14 +18,16 @@ package io.element.android.libraries.mediaviewer.api.local
|
|||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.Image
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.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.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.rememberPdfViewerState
|
||||
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.rememberZoomableImageState
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Composable
|
||||
fun LocalMediaView(
|
||||
localMedia: LocalMedia?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
|
||||
mediaInfo: MediaInfo? = localMedia?.info,
|
||||
) {
|
||||
val zoomableState = rememberZoomableState(
|
||||
zoomSpec = ZoomSpec(maxZoomFactor = 5f)
|
||||
)
|
||||
val mimeType = mediaInfo?.mimeType
|
||||
when {
|
||||
mimeType.isMimeTypeImage() -> MediaImageView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = localMedia,
|
||||
zoomableState = zoomableState,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
)
|
||||
mimeType.isMimeTypeVideo() -> MediaVideoView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = localMedia,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
)
|
||||
mimeType == MimeTypes.Pdf -> MediaPDFView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = localMedia,
|
||||
zoomableState = zoomableState,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
)
|
||||
// TODO handle audio with exoplayer
|
||||
else -> MediaFileView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
uri = localMedia?.uri,
|
||||
info = mediaInfo,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -122,24 +124,25 @@ fun LocalMediaView(
|
|||
private fun MediaImageView(
|
||||
localMediaViewState: LocalMediaViewState,
|
||||
localMedia: LocalMedia?,
|
||||
zoomableState: ZoomableState,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Image(
|
||||
painter = painterResource(id = CommonDrawables.sample_background),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = modifier,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
val zoomableImageState = rememberZoomableImageState(zoomableState)
|
||||
val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState)
|
||||
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
|
||||
ZoomableAsyncImage(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
modifier = modifier,
|
||||
state = zoomableImageState,
|
||||
model = localMedia?.uri,
|
||||
contentDescription = stringResource(id = CommonStrings.common_image),
|
||||
contentScale = ContentScale.Fit,
|
||||
onClick = { onClick() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -149,8 +152,14 @@ private fun MediaImageView(
|
|||
private fun MediaVideoView(
|
||||
localMediaViewState: LocalMediaViewState,
|
||||
localMedia: LocalMedia?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var playableState: PlayableState.Playable by remember {
|
||||
mutableStateOf(PlayableState.Playable(isPlaying = false, isShowingControls = false))
|
||||
}
|
||||
localMediaViewState.playableState = playableState
|
||||
|
||||
val context = LocalContext.current
|
||||
val playerListener = object : Player.Listener {
|
||||
override fun onRenderedFirstFrame() {
|
||||
|
|
@ -158,7 +167,7 @@ private fun MediaVideoView(
|
|||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
localMediaViewState.isPlaying = isPlaying
|
||||
playableState = playableState.copy(isPlaying = isPlaying)
|
||||
}
|
||||
}
|
||||
val exoPlayer = remember {
|
||||
|
|
@ -176,19 +185,34 @@ private fun MediaVideoView(
|
|||
} else {
|
||||
exoPlayer.setMediaItems(emptyList())
|
||||
}
|
||||
KeepScreenOn(localMediaViewState.isPlaying)
|
||||
KeepScreenOn(playableState.isPlaying)
|
||||
AndroidView(
|
||||
factory = {
|
||||
PlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
setShowPreviousButton(false)
|
||||
setShowNextButton(false)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
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 ->
|
||||
|
|
@ -208,15 +232,19 @@ private fun MediaVideoView(
|
|||
private fun MediaPDFView(
|
||||
localMediaViewState: LocalMediaViewState,
|
||||
localMedia: LocalMedia?,
|
||||
zoomableState: ZoomableState,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val pdfViewerState = rememberPdfViewerState(
|
||||
model = localMedia?.uri,
|
||||
zoomableState = zoomableState
|
||||
zoomableState = localMediaViewState.zoomableState,
|
||||
)
|
||||
localMediaViewState.isReady = pdfViewerState.isLoaded
|
||||
PdfViewer(pdfViewerState = pdfViewerState, modifier = modifier)
|
||||
PdfViewer(
|
||||
pdfViewerState = pdfViewerState,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -224,11 +252,23 @@ private fun MediaFileView(
|
|||
localMediaViewState: LocalMediaViewState,
|
||||
uri: Uri?,
|
||||
info: MediaInfo?,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
|
||||
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) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
|
|||
|
|
@ -17,21 +17,35 @@
|
|||
package io.element.android.libraries.mediaviewer.api.local
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import me.saket.telephoto.zoomable.ZoomableState
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
|
||||
@Stable
|
||||
class LocalMediaViewState {
|
||||
class LocalMediaViewState internal constructor(
|
||||
val zoomableState: ZoomableState,
|
||||
) {
|
||||
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
|
||||
fun rememberLocalMediaViewState(): LocalMediaViewState {
|
||||
return remember {
|
||||
LocalMediaViewState()
|
||||
fun rememberLocalMediaViewState(zoomableState: ZoomableState = rememberZoomableState()): LocalMediaViewState {
|
||||
return remember(zoomableState) {
|
||||
LocalMediaViewState(zoomableState)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.foundation.background
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
|
|
@ -47,10 +48,15 @@ import me.saket.telephoto.zoomable.zoomable
|
|||
@Composable
|
||||
fun PdfViewer(
|
||||
pdfViewerState: PdfViewerState,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier.zoomable(pdfViewerState.zoomableState),
|
||||
modifier = modifier
|
||||
.zoomable(
|
||||
state = pdfViewerState.zoomableState,
|
||||
onClick = { onClick() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val maxWidthInPx = maxWidth.roundToPx()
|
||||
|
|
@ -61,7 +67,10 @@ fun PdfViewer(
|
|||
}
|
||||
}
|
||||
val pdfPages = pdfViewerState.getPages()
|
||||
PdfPagesView(pdfPages.toImmutableList(), pdfViewerState.lazyListState)
|
||||
PdfPagesView(
|
||||
pdfPages = pdfPages.toImmutableList(),
|
||||
lazyListState = pdfViewerState.lazyListState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,8 +83,12 @@ private fun PdfPagesView(
|
|||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
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 ->
|
||||
val pdfPage = pdfPages[index]
|
||||
PdfPageView(pdfPage)
|
||||
|
|
|
|||
|
|
@ -19,34 +19,36 @@
|
|||
package io.element.android.libraries.mediaviewer.api.viewer
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
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.platform.LocalInspectionMode
|
||||
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.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
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.LocalMediaView
|
||||
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.ui.strings.CommonStrings
|
||||
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
|
||||
fun MediaViewerView(
|
||||
state: MediaViewerState,
|
||||
onBackPressed: () -> Unit,
|
||||
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() {
|
||||
state.eventSink(MediaViewerEvents.RetryLoading)
|
||||
|
|
@ -83,65 +134,107 @@ fun MediaViewerView(
|
|||
state.eventSink(MediaViewerEvents.ClearLoadingError)
|
||||
}
|
||||
|
||||
val localMediaViewState = rememberLocalMediaViewState()
|
||||
val showThumbnail = !localMediaViewState.isReady
|
||||
val showProgress = rememberShowProgress(state.downloadedMedia)
|
||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
val currentShowOverlay by rememberUpdatedState(showOverlay)
|
||||
val currentOnShowOverlayChanged by rememberUpdatedState(onShowOverlayChanged)
|
||||
val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)
|
||||
|
||||
Scaffold(
|
||||
modifier,
|
||||
topBar = {
|
||||
MediaViewerTopBar(
|
||||
actionsEnabled = state.downloadedMedia is AsyncData.Success,
|
||||
mimeType = state.mediaInfo.mimeType,
|
||||
onBackPressed = onBackPressed,
|
||||
canDownload = state.canDownload,
|
||||
canShare = state.canShare,
|
||||
eventSink = state.eventSink
|
||||
)
|
||||
DismissFlickEffects(
|
||||
flickState = flickState,
|
||||
onDismissing = { animationDuration ->
|
||||
delay(animationDuration / 3)
|
||||
onDismiss()
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
onDragging = {
|
||||
currentOnShowOverlayChanged(false)
|
||||
}
|
||||
)
|
||||
|
||||
FlickToDismiss(
|
||||
state = flickState,
|
||||
modifier = modifier.background(backgroundColorFor(flickState))
|
||||
) {
|
||||
Column(
|
||||
val showProgress = rememberShowProgress(state.downloadedMedia)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(it),
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val zoomableState = rememberZoomableState(
|
||||
zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (state.downloadedMedia is AsyncData.Failure) {
|
||||
val localMediaViewState = rememberLocalMediaViewState(zoomableState)
|
||||
val showThumbnail = !localMediaViewState.isReady
|
||||
val playableState = localMediaViewState.playableState
|
||||
val showError = state.downloadedMedia is AsyncData.Failure
|
||||
|
||||
LaunchedEffect(playableState) {
|
||||
if (playableState is PlayableState.Playable) {
|
||||
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(
|
||||
errorMessage = stringResource(id = CommonStrings.error_unknown),
|
||||
onRetry = ::onRetry,
|
||||
onDismiss = ::onDismissError
|
||||
)
|
||||
}
|
||||
LocalMediaView(
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = state.downloadedMedia.dataOrNull(),
|
||||
mediaInfo = state.mediaInfo,
|
||||
)
|
||||
ThumbnailView(
|
||||
mediaInfo = state.mediaInfo,
|
||||
thumbnailSource = state.thumbnailSource,
|
||||
showThumbnail = showThumbnail,
|
||||
}
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolean {
|
||||
var showProgress by remember {
|
||||
|
|
@ -175,6 +268,9 @@ private fun MediaViewerTopBar(
|
|||
) {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent.copy(0.6f),
|
||||
),
|
||||
navigationIcon = { BackButton(onClick = onBackPressed) },
|
||||
actions = {
|
||||
IconButton(
|
||||
|
|
@ -227,26 +323,28 @@ private fun MediaViewerTopBar(
|
|||
@Composable
|
||||
private fun ThumbnailView(
|
||||
thumbnailSource: MediaSource?,
|
||||
showThumbnail: Boolean,
|
||||
isVisible: Boolean,
|
||||
mediaInfo: MediaInfo,
|
||||
zoomableState: ZoomableState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = showThumbnail,
|
||||
visible = isVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val mediaRequestData = MediaRequestData(
|
||||
source = thumbnailSource,
|
||||
kind = MediaRequestData.Kind.File(mediaInfo.name, mediaInfo.mimeType)
|
||||
)
|
||||
AsyncImage(
|
||||
ZoomableAsyncImage(
|
||||
state = rememberZoomableImageState(zoomableState),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
model = mediaRequestData,
|
||||
alpha = 0.8f,
|
||||
contentScale = ContentScale.Fit,
|
||||
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.
|
||||
@Preview
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3008d0953bbe1135796e50e1c1175c25f3e138892c7bd431444525213c9c91b8
|
||||
size 396053
|
||||
oid sha256:5d8c8580f22a7dbf6ac546e0c741259109e039f9e2dbc1bdc48966031b4f7cdb
|
||||
size 396496
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2909eab36362e1c4ca470ef65bb6fabfe9c410e9a6666a2d14bb65f8b753c203
|
||||
size 16237
|
||||
oid sha256:4b209f5c4f0775eeb4059fa26a0fe9468301b0b82c5bf5627a93a01cb7a3e6d8
|
||||
size 16609
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:09d54a1201ffbbfdc0f63eaf075f66c2c446b6b773f8929226a2455118710de7
|
||||
size 64620
|
||||
oid sha256:28a52e7e011a59fbeeaf2477d4fc15f3ea8f615906d0dbf3642a3e7473d32cbc
|
||||
size 49465
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5f50bf3f0c23362762b9911352179d428821da26cbf5cdfc88dd31285ae8b478
|
||||
size 100137
|
||||
oid sha256:8507b04315e9f112e962e4aa3d572f85f5985f6dd9153b9fe5e0572500de1dfe
|
||||
size 90187
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3e380cb38a6efcf9421ad6f86fbcebe82191c4d202dc6fbfb408b0c20b9c4928
|
||||
size 395461
|
||||
oid sha256:4454f74e8b205627df523773938a9f6f689116446a29fe3098da03c158bd1d37
|
||||
size 396004
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d53a17bf0c1e43175d282a85923d184ea5bb913f73367916121eefc74ea82d4c
|
||||
size 395464
|
||||
oid sha256:a9e99773ba190bc9e08faa644568ec576064967b9367ee0f6e324f720b454680
|
||||
size 396007
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9da218de660ad16ae8befe015703afe654dc9eef033d4edb00467ad3da4923a9
|
||||
size 394418
|
||||
oid sha256:617afc64adfcf660ba28a9c0cc7a8089efe76b0b9d382dfc4ae9ea29e02130e0
|
||||
size 394862
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dfb06486f14c7ca54d49c69659a2861fc6c1a0b09b8bcd3e390c4126061bd83a
|
||||
size 114684
|
||||
oid sha256:e20d3b49668069aa81d457b2fd6ce6ce2e4e72f8897b55db29ae8049bba13669
|
||||
size 100033
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:52486cc2cf9a3e521916c794d41df91b35b3384a47b38d97fb33bfde8e03e630
|
||||
size 395696
|
||||
oid sha256:5fff1c9ca69312b781cc6f61b5731e46fefdbe4aab0773ac0f82c11a1d537186
|
||||
size 396253
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a8d17201eaa89a6ac0cd503796508718d6ae515240e521acfa666a19daa2be14
|
||||
size 6662
|
||||
oid sha256:cf994c9d5bc4845c3c949e034cc2a920085e823749e60817f75e1bfbb4d8e2ad
|
||||
size 6769
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e75209484e3ed4f8d89b82004530af59a7c8e8035ce38bcbd8a0e5d64e32016c
|
||||
size 15920
|
||||
oid sha256:5bee1fe2ec77bc2d15e3b05202c8897724c89ce774db62211ddc7a94d220146c
|
||||
size 16891
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:00491f1a53c4f05c96f019e97ef006dfa2d13f9980d4b567b14e63ffdf06a870
|
||||
size 16074
|
||||
oid sha256:ece7a72c5f5d3c0aec96568ad0188733dfb34b5169bb57a395f28a2da070f877
|
||||
size 17121
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b3809d38f5bd32392612b943ea17113948d8215707c92f919eb7d4e147a2cc3b
|
||||
size 14428
|
||||
oid sha256:df72ca3aead93edbb4dab70efc7728894a899845c05092fe5af2c73b65d8c213
|
||||
size 15520
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:be54d8f8ea0d5e376aae0304aefe9c236b8eb681b059f7a5e35ec32d0741dba9
|
||||
size 14549
|
||||
oid sha256:584fa976463bed507362846401b73662051dc8b8475301b0f48054f2464e9e60
|
||||
size 15685
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue