Browse Source

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

[Improvement] MediaViewer/Attachment experience
pull/2444/head
ganfra 7 months ago committed by GitHub
parent
commit
3213847a5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      changelog.d/2390.feature
  2. 58
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt
  3. 4
      gradle/libs.versions.toml
  4. 1
      libraries/mediaviewer/api/build.gradle.kts
  5. 94
      libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt
  6. 24
      libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt
  7. 19
      libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/pdf/PdfViewer.kt
  8. 199
      libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt
  9. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.attachments.preview_AttachmentsPreviewView_null_AttachmentsPreviewView_0_null_0,NEXUS_5,1.0,en].png
  10. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.attachments.preview_AttachmentsPreviewView_null_AttachmentsPreviewView_0_null_1,NEXUS_5,1.0,en].png
  11. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.attachments.preview_AttachmentsPreviewView_null_AttachmentsPreviewView_0_null_2,NEXUS_5,1.0,en].png
  12. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.attachments.preview_AttachmentsPreviewView_null_AttachmentsPreviewView_0_null_3,NEXUS_5,1.0,en].png
  13. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_0,NEXUS_5,1.0,en].png
  14. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_1,NEXUS_5,1.0,en].png
  15. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_10,NEXUS_5,1.0,en].png
  16. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_2,NEXUS_5,1.0,en].png
  17. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_3,NEXUS_5,1.0,en].png
  18. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_5,NEXUS_5,1.0,en].png
  19. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_6,NEXUS_5,1.0,en].png
  20. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_7,NEXUS_5,1.0,en].png
  21. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_8,NEXUS_5,1.0,en].png
  22. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_9,NEXUS_5,1.0,en].png

1
changelog.d/2390.feature

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

58
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt

@ -16,11 +16,12 @@ @@ -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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -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( @@ -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)
}

4
gradle/libs.versions.toml

@ -39,6 +39,7 @@ showkase = "1.0.2" @@ -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" @@ -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"

1
libraries/mediaviewer/api/build.gradle.kts

@ -39,6 +39,7 @@ dependencies { @@ -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)

94
libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt

@ -18,14 +18,16 @@ package io.element.android.libraries.mediaviewer.api.local @@ -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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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

24
libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt

@ -17,21 +17,35 @@ @@ -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)
}
}

19
libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/pdf/PdfViewer.kt

@ -21,6 +21,7 @@ import androidx.compose.foundation.background @@ -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 @@ -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( @@ -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( @@ -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)

199
libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt

@ -19,34 +19,36 @@ @@ -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,9 +67,19 @@ import io.element.android.libraries.mediaviewer.api.R @@ -65,9 +67,19 @@ 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(
@ -75,22 +87,25 @@ fun MediaViewerView( @@ -75,22 +87,25 @@ fun MediaViewerView(
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onRetry() {
state.eventSink(MediaViewerEvents.RetryLoading)
}
fun onDismissError() {
state.eventSink(MediaViewerEvents.ClearLoadingError)
}
val localMediaViewState = rememberLocalMediaViewState()
val showThumbnail = !localMediaViewState.isReady
val showProgress = rememberShowProgress(state.downloadedMedia)
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
Scaffold(
modifier,
topBar = {
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,
@ -99,49 +114,127 @@ fun MediaViewerView( @@ -99,49 +114,127 @@ fun MediaViewerView(
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)
}
fun onDismissError() {
state.eventSink(MediaViewerEvents.ClearLoadingError)
}
val currentShowOverlay by rememberUpdatedState(showOverlay)
val currentOnShowOverlayChanged by rememberUpdatedState(onShowOverlayChanged)
val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)
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) {
ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry,
onDismiss = ::onDismissError
)
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,
showThumbnail = showThumbnail,
isVisible = showThumbnail,
zoomableState = zoomableState
)
if (showError) {
ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry,
onDismiss = ::onDismissError
)
}
}
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( @@ -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( @@ -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( @@ -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

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.attachments.preview_AttachmentsPreviewView_null_AttachmentsPreviewView_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.attachments.preview_AttachmentsPreviewView_null_AttachmentsPreviewView_0_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.attachments.preview_AttachmentsPreviewView_null_AttachmentsPreviewView_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.attachments.preview_AttachmentsPreviewView_null_AttachmentsPreviewView_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_10,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_7,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_8,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.mediaviewer.api.viewer_MediaViewerView_null_MediaViewerView_0_null_9,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save