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..e440a8a9cb 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 @@ -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) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8279da11f..68fe3b6e06 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..1e8261d431 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,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): 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 diff --git a/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 b/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 index f0ae2c4156..c66cb0eb39 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3008d0953bbe1135796e50e1c1175c25f3e138892c7bd431444525213c9c91b8 -size 396053 +oid sha256:5d8c8580f22a7dbf6ac546e0c741259109e039f9e2dbc1bdc48966031b4f7cdb +size 396496 diff --git a/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 b/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 index c879d11489..e6f4fc0f28 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2909eab36362e1c4ca470ef65bb6fabfe9c410e9a6666a2d14bb65f8b753c203 -size 16237 +oid sha256:4b209f5c4f0775eeb4059fa26a0fe9468301b0b82c5bf5627a93a01cb7a3e6d8 +size 16609 diff --git a/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 b/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 index 498c459cc5..dbc89c0d9b 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09d54a1201ffbbfdc0f63eaf075f66c2c446b6b773f8929226a2455118710de7 -size 64620 +oid sha256:28a52e7e011a59fbeeaf2477d4fc15f3ea8f615906d0dbf3642a3e7473d32cbc +size 49465 diff --git a/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 b/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 index 76bb1a06d3..6f5a768e70 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f50bf3f0c23362762b9911352179d428821da26cbf5cdfc88dd31285ae8b478 -size 100137 +oid sha256:8507b04315e9f112e962e4aa3d572f85f5985f6dd9153b9fe5e0572500de1dfe +size 90187 diff --git a/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 b/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 index a0ba0c8ba7..f33157f1a9 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e380cb38a6efcf9421ad6f86fbcebe82191c4d202dc6fbfb408b0c20b9c4928 -size 395461 +oid sha256:4454f74e8b205627df523773938a9f6f689116446a29fe3098da03c158bd1d37 +size 396004 diff --git a/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 b/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 index e4b9d725fa..6cd8a47580 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d53a17bf0c1e43175d282a85923d184ea5bb913f73367916121eefc74ea82d4c -size 395464 +oid sha256:a9e99773ba190bc9e08faa644568ec576064967b9367ee0f6e324f720b454680 +size 396007 diff --git a/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 b/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 index 0514768fd4..8d13f91b77 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9da218de660ad16ae8befe015703afe654dc9eef033d4edb00467ad3da4923a9 -size 394418 +oid sha256:617afc64adfcf660ba28a9c0cc7a8089efe76b0b9d382dfc4ae9ea29e02130e0 +size 394862 diff --git a/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 b/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 index 4f4bee10ac..4c3c4f9cd3 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dfb06486f14c7ca54d49c69659a2861fc6c1a0b09b8bcd3e390c4126061bd83a -size 114684 +oid sha256:e20d3b49668069aa81d457b2fd6ce6ce2e4e72f8897b55db29ae8049bba13669 +size 100033 diff --git a/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 b/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 index 5c0e2c4762..436d2bad5c 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:52486cc2cf9a3e521916c794d41df91b35b3384a47b38d97fb33bfde8e03e630 -size 395696 +oid sha256:5fff1c9ca69312b781cc6f61b5731e46fefdbe4aab0773ac0f82c11a1d537186 +size 396253 diff --git a/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 b/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 index 44e1fa8767..1910273e18 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8d17201eaa89a6ac0cd503796508718d6ae515240e521acfa666a19daa2be14 -size 6662 +oid sha256:cf994c9d5bc4845c3c949e034cc2a920085e823749e60817f75e1bfbb4d8e2ad +size 6769 diff --git a/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 b/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 index 271cc24b39..99edc11c9d 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e75209484e3ed4f8d89b82004530af59a7c8e8035ce38bcbd8a0e5d64e32016c -size 15920 +oid sha256:5bee1fe2ec77bc2d15e3b05202c8897724c89ce774db62211ddc7a94d220146c +size 16891 diff --git a/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 b/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 index d6992d79e5..5173c6ea3b 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00491f1a53c4f05c96f019e97ef006dfa2d13f9980d4b567b14e63ffdf06a870 -size 16074 +oid sha256:ece7a72c5f5d3c0aec96568ad0188733dfb34b5169bb57a395f28a2da070f877 +size 17121 diff --git a/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 b/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 index 03831dc3c4..88dd5139e5 100644 --- a/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 +++ b/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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b3809d38f5bd32392612b943ea17113948d8215707c92f919eb7d4e147a2cc3b -size 14428 +oid sha256:df72ca3aead93edbb4dab70efc7728894a899845c05092fe5af2c73b65d8c213 +size 15520 diff --git a/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 b/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 index 282d9de679..6b8287d16d 100644 --- a/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 +++ b/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,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:be54d8f8ea0d5e376aae0304aefe9c236b8eb681b059f7a5e35ec32d0741dba9 -size 14549 +oid sha256:584fa976463bed507362846401b73662051dc8b8475301b0f48054f2464e9e60 +size 15685