diff --git a/changelog.d/2390.feature b/changelog.d/2390.feature new file mode 100644 index 0000000000..c58061ea5b --- /dev/null +++ b/changelog.d/2390.feature @@ -0,0 +1 @@ +MediaViewer : introduce fullscreen and flick to dismiss behavior. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 45f83568a3..8267e62cea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -132,7 +132,8 @@ private fun AttachmentPreviewContent( ) { when (attachment) { is Attachment.Media -> LocalMediaView( - localMedia = attachment.localMedia + localMedia = attachment.localMedia, + onClick = {} ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 49c297ccea..b9f2fddd7c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/libraries/mediaviewer/api/build.gradle.kts b/libraries/mediaviewer/api/build.gradle.kts index 7076a144fe..2745302623 100644 --- a/libraries/mediaviewer/api/build.gradle.kts +++ b/libraries/mediaviewer/api/build.gradle.kts @@ -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) diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt index f2d661dead..24143b5a95 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaView.kt +++ b/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 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 diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt index 07f891c90c..dc37abec6e 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/LocalMediaViewState.kt @@ -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) } } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/pdf/PdfViewer.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/pdf/PdfViewer.kt index a4b523d45d..ba68f401aa 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/local/pdf/PdfViewer.kt +++ b/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 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) diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt index 0b60e785f9..8a89e22704 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt @@ -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 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( 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( canShare = state.canShare, eventSink = state.eventSink ) + } + } +} + +@Composable +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): 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