Browse Source

MediaViewer : introduce fullscreen and flick to dismiss behavior

pull/2438/head
ganfra 7 months ago
parent
commit
22676cc5eb
  1. 1
      changelog.d/2390.feature
  2. 3
      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

1
changelog.d/2390.feature

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

3
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) { when (attachment) {
is Attachment.Media -> LocalMediaView( is Attachment.Media -> LocalMediaView(
localMedia = attachment.localMedia localMedia = attachment.localMedia,
onClick = {}
) )
} }
} }

4
gradle/libs.versions.toml

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

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

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

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

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

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

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

Loading…
Cancel
Save