mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3982 from vitorpamplona/claude/picture-dialog-button-clickability-7q2whv
fix(viewer): full-screen viewer chrome — clickable buttons, PDF parity, and chrome that follows the system bars
This commit is contained in:
+343
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.view.Window
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
// Chrome shared by the full-screen media viewers -- the zoomable image/video dialog and the PDF
|
||||
// viewer. Both are opened the same way (tap a media card in a feed), so they immerse, auto-hide,
|
||||
// and lay their controls out identically.
|
||||
|
||||
// Opening churn -- insets arriving, then the bars being hidden -- must not look like a user
|
||||
// gesture, so the row snaps through it and only animates afterwards.
|
||||
private const val CONTROLS_SETTLE_BEFORE_ANIMATING_MS = 350L
|
||||
|
||||
// Roughly the system bars' own show/hide duration, so the row travels with them rather than
|
||||
// trailing after they have already arrived.
|
||||
private const val CONTROLS_SLIDE_MS = 200
|
||||
|
||||
// Keeps the row off the screen edge -- and off the rounded corners -- while the bars are hidden.
|
||||
private val VIEWER_CHROME_EDGE_GAP = 16.dp
|
||||
|
||||
// How long the controls stay up before the viewer fades them out on its own.
|
||||
private const val CONTROLS_AUTO_HIDE_DELAY_MS = 2000L
|
||||
|
||||
/**
|
||||
* Goes fully immersive for as long as the viewer is on screen: hides both OS bars and restores them
|
||||
* on the way out. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user swipe to peek the bars back.
|
||||
*/
|
||||
@Composable
|
||||
fun ImmersiveSystemBarsEffect(window: Window?) {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(window, view) {
|
||||
val controller = window?.let { WindowInsetsControllerCompat(it, view) }
|
||||
controller?.apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility of the viewer controls: they start on screen, fade out on their own after
|
||||
* [CONTROLS_AUTO_HIDE_DELAY_MS], and the caller flips the returned state on tap.
|
||||
*
|
||||
* [holdOpen] freezes the timer while something anchored to the controls -- the share sheet, say --
|
||||
* is up, and re-arms it once that closes. [armed] withholds the countdown until there is something
|
||||
* to look at, so a viewer that spends three seconds fetching its media doesn't reveal the first
|
||||
* frame with the controls already gone.
|
||||
*
|
||||
* A tap that brings the controls back deliberately gets no timer: the user asked for them, so they
|
||||
* stay until tapped away. That is why the countdown races the controls going away rather than just
|
||||
* sleeping -- a timer left over from an earlier show would otherwise wipe controls the user tapped
|
||||
* back up in the meantime.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberViewerControlsVisibility(
|
||||
holdOpen: Boolean,
|
||||
armed: Boolean = true,
|
||||
): MutableState<Boolean> {
|
||||
val visible = remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(armed, holdOpen) {
|
||||
if (!armed || holdOpen) return@LaunchedEffect
|
||||
|
||||
val hiddenFirst =
|
||||
withTimeoutOrNull(CONTROLS_AUTO_HIDE_DELAY_MS) {
|
||||
snapshotFlow { visible.value }.first { !it }
|
||||
}
|
||||
|
||||
if (hiddenFirst == null) visible.value = false
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the viewer chrome sits from a screen edge: the system bar's own height while the bar is
|
||||
* on screen, and a thin constant once it is hidden -- so the chrome follows the bar instead of
|
||||
* reserving space for one that is not there.
|
||||
*
|
||||
* This only works because the viewer asks for BEHAVIOR_DEFAULT rather than transient bars. Under
|
||||
* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE a peeked bar is painted over the content and dispatches no
|
||||
* insets at all -- `systemBars` stays 0 and `isVisible` stays false the whole time it is on screen
|
||||
* -- so nothing here could react to it.
|
||||
*
|
||||
* The value is animated, but snapped for [CONTROLS_SETTLE_BEFORE_ANIMATING_MS] after the chrome
|
||||
* appears. Opening moves the inset twice for reasons the user did not cause: the window has not
|
||||
* been told its insets yet (they read 0), and ImmersiveSystemBarsEffect hides the bars from a
|
||||
* DisposableEffect that runs after composition. Animating either would play a slide on open.
|
||||
*/
|
||||
@Composable
|
||||
fun animatedViewerChromeInset(atBottom: Boolean): Dp {
|
||||
val density = LocalDensity.current
|
||||
val bars = WindowInsets.systemBars
|
||||
val barPx = if (atBottom) bars.getBottom(density) else bars.getTop(density)
|
||||
val target = with(density) { maxOf(barPx, VIEWER_CHROME_EDGE_GAP.roundToPx()).toDp() }
|
||||
|
||||
var settled by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(CONTROLS_SETTLE_BEFORE_ANIMATING_MS)
|
||||
settled = true
|
||||
}
|
||||
|
||||
val animated by animateDpAsState(
|
||||
targetValue = target,
|
||||
animationSpec = if (settled) tween(durationMillis = CONTROLS_SLIDE_MS) else snap(),
|
||||
label = "viewerChromeInset",
|
||||
)
|
||||
return animated
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays a viewer control row along a screen edge -- the top by default, the bottom when [atBottom].
|
||||
*
|
||||
* The viewer hides the system bars, so the row would otherwise sit against the screen edge. It
|
||||
* takes its distance from [animatedViewerChromeInset], which follows the bar on and off screen
|
||||
* rather than permanently reserving room for it.
|
||||
*
|
||||
* Horizontal display-cutout insets are still applied outright: a landscape notch eats into the
|
||||
* sides whatever the bars are doing. The top cutout is deliberately not applied, because on a
|
||||
* punch-hole device it is a centred hole that the edge-anchored buttons are nowhere near -- and
|
||||
* honouring it as a full-width top inset would push them down by the height of a camera they do
|
||||
* not overlap.
|
||||
*
|
||||
* The row also holds a button's height whatever it carries, so content that outlives the buttons
|
||||
* doesn't shift as they come and go.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun ViewerControlsRow(
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalArrangement: Arrangement.Horizontal = spacedBy(Size10dp),
|
||||
atBottom: Boolean = false,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
// systemBars is visibility-aware under BEHAVIOR_DEFAULT: 0 while the bars are hidden, and the
|
||||
// real bar size once the user swipes them in -- so the controls follow them instead of sitting
|
||||
// under a bar or reserving space for one that is not there. The 16dp floor keeps them clear of
|
||||
// the rounded corners while hidden. Animated so the row slides rather than jumps.
|
||||
val animatedInset = animatedViewerChromeInset(atBottom)
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.windowInsetsPadding(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal))
|
||||
.padding(
|
||||
top = if (atBottom) 0.dp else animatedInset,
|
||||
bottom = if (atBottom) animatedInset else 0.dp,
|
||||
).padding(horizontal = Size15dp, vertical = Size10dp)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = ButtonDefaults.MinHeight),
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** Leaves the viewer. Always the first item in the control row. */
|
||||
@Composable
|
||||
fun ViewerBackButton(onDismiss: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the share sheet for whatever the viewer is showing. The sheet anchors to the button. */
|
||||
@Composable
|
||||
fun ViewerShareButton(
|
||||
content: BaseMediaContent,
|
||||
popupExpanded: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { popupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
)
|
||||
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = popupExpanded,
|
||||
content = content,
|
||||
onDismiss = { popupExpanded.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the media to the gallery. Q and up write through MediaStore and need no permission; older
|
||||
* releases ask for WRITE_EXTERNAL_STORAGE first and save as soon as it is granted.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
fun ViewerSaveToGalleryButton(
|
||||
content: BaseMediaContent,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// The application context and the view model's scope, never the composition's: this button
|
||||
// lives inside the AnimatedVisibility that the auto-hide collapses two seconds after the tap
|
||||
// that started the download, and a rememberCoroutineScope job would be cancelled with it --
|
||||
// killing the save with no file and no error. Matches the download row in ShareMediaAction.
|
||||
val localContext = LocalContext.current.applicationContext
|
||||
val scope = accountViewModel.viewModelScope
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
scope.launch {
|
||||
saveMediaToGallery(content, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
saveMediaToGallery(content, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.download_to_phone),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
@@ -61,14 +60,15 @@ fun SlidingCarousel(
|
||||
) {
|
||||
HorizontalPager(state = pagerState) { page -> itemContent(page) }
|
||||
|
||||
// you can remove the surface in case you don't want
|
||||
// the transparent bacground
|
||||
// The dots ride the same animated inset as the rest of the viewer chrome, so they slide
|
||||
// with the navigation bar instead of snapping the moment it appears. navigationBarsPadding()
|
||||
// tracks the bar correctly but moves in one frame, which reads as a jump next to the
|
||||
// controls sliding at the top.
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 8.dp),
|
||||
.padding(bottom = animatedViewerChromeInset(atBottom = true) + 8.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.Black.copy(alpha = 0.5f),
|
||||
) {
|
||||
|
||||
+11
-145
@@ -20,9 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.WindowManager
|
||||
@@ -35,31 +33,22 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -71,22 +60,13 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo
|
||||
@@ -101,19 +81,11 @@ import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.ZoomState
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
@@ -211,18 +183,9 @@ fun ZoomableImageDialog(
|
||||
dialogWindow.attributes = attributes
|
||||
}
|
||||
|
||||
// Go fully immersive while the full-screen media viewer is open: hide both OS bars and
|
||||
// restore them when the dialog closes. BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE lets the user
|
||||
// swipe to peek the bars. Applies to full-screen images and video alike (shared dialog).
|
||||
val dialogView = LocalView.current
|
||||
DisposableEffect(dialogWindow, dialogView) {
|
||||
val controller = dialogWindow?.let { WindowInsetsControllerCompat(it, dialogView) }
|
||||
controller?.apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
// Go fully immersive while the full-screen media viewer is open. Applies to full-screen
|
||||
// images and video alike (shared dialog), and matches the PDF viewer.
|
||||
ImmersiveSystemBarsEffect(dialogWindow)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Background surface that fades in as the content grows to fullscreen.
|
||||
@@ -250,7 +213,6 @@ fun ZoomableImageDialog(
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
private fun DialogContent(
|
||||
allImages: ImmutableList<BaseMediaContent>,
|
||||
imageUrl: BaseMediaContent,
|
||||
@@ -264,29 +226,13 @@ private fun DialogContent(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val pagerState: PagerState = rememberPagerState { allImages.size }
|
||||
val controllerVisible = remember { mutableStateOf(true) }
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
val controllerVisible = rememberViewerControlsVisibility(holdOpen = sharePopupExpanded.value)
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
launch {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
delay(2000)
|
||||
if (!sharePopupExpanded.value) {
|
||||
controllerVisible.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-trigger auto-hide after the share dialog is dismissed
|
||||
LaunchedEffect(sharePopupExpanded.value) {
|
||||
if (!sharePopupExpanded.value && controllerVisible.value) {
|
||||
delay(2000)
|
||||
controllerVisible.value = false
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,100 +336,20 @@ private fun DialogContent(
|
||||
// Also fade with the grow animation so controls appear/disappear alongside it.
|
||||
modifier = Modifier.graphicsLayer { alpha = progress().coerceIn(0f, 1f) },
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = Size15dp, vertical = Size10dp)
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
}
|
||||
ViewerControlsRow {
|
||||
ViewerBackButton(onDismiss)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
|
||||
if (myContent is MediaUrlImage || myContent is MediaLocalImage) {
|
||||
OutlinedButton(
|
||||
onClick = { sharePopupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
)
|
||||
|
||||
ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = sharePopupExpanded, myContent, onDismiss = { sharePopupExpanded.value = false })
|
||||
}
|
||||
ViewerShareButton(myContent, sharePopupExpanded, accountViewModel)
|
||||
}
|
||||
|
||||
val isPdfOrStaticImage = myContent is MediaUrlImage || myContent is MediaLocalImage || myContent is MediaUrlPdf
|
||||
val isNotLiveStream = myContent !is MediaUrlContent || !isHlsMedia(myContent.url, myContent.mimeType)
|
||||
if (isPdfOrStaticImage && isNotLiveStream) {
|
||||
val localContext = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
scope.launch {
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.media_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.download_to_phone),
|
||||
)
|
||||
}
|
||||
ViewerSaveToGalleryButton(myContent, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+100
-77
@@ -23,24 +23,23 @@ package com.vitorpamplona.amethyst.ui.components.pdf
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -62,21 +61,22 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.graphics.createBitmap
|
||||
import coil3.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.components.ImmersiveSystemBarsEffect
|
||||
import com.vitorpamplona.amethyst.ui.components.ViewerBackButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ViewerControlsRow
|
||||
import com.vitorpamplona.amethyst.ui.components.ViewerSaveToGalleryButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ViewerShareButton
|
||||
import com.vitorpamplona.amethyst.ui.components.getDialogWindow
|
||||
import com.vitorpamplona.amethyst.ui.components.rememberViewerControlsVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -106,6 +106,10 @@ private const val HI_RES_DEBOUNCE_MS = 200L
|
||||
// threshold region where we swap in the hi-res bitmap.
|
||||
private const val DOUBLE_TAP_ZOOM_SCALE = 2.5f
|
||||
|
||||
// How long the page counter stays up on its own after a page turn, once the reader has hidden the
|
||||
// chrome. Long enough to read "7 / 24" without putting the controls back on top of the page.
|
||||
private const val PAGE_INDICATOR_FLASH_MS = 1500L
|
||||
|
||||
// How many recently-rendered pages to keep around. Pager already pre-composes the
|
||||
// current page plus one neighbor; this just speeds up small back/forward swipes.
|
||||
// At VIEWER_MAX_DIM_PX = 3072 this caps memory at ~80 MB worth of page bitmaps.
|
||||
@@ -165,6 +169,9 @@ fun PdfViewerDialog(
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
// Go fully immersive while the viewer is open, exactly like the image/video dialog.
|
||||
ImmersiveSystemBarsEffect(getDialogWindow())
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color.Black) {
|
||||
PdfViewerContent(
|
||||
content = content,
|
||||
@@ -219,31 +226,44 @@ private fun PdfViewerContent(
|
||||
}
|
||||
|
||||
val sharePopupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
ShareMediaAction(
|
||||
accountViewModel = accountViewModel,
|
||||
popupExpanded = sharePopupExpanded,
|
||||
content = content,
|
||||
onDismiss = { sharePopupExpanded.value = false },
|
||||
)
|
||||
|
||||
val handle = handleState
|
||||
if (handle == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
} else if (handle.pageCount == 0) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Unable to open PDF",
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val pagerState = rememberPagerState { handle.pageCount }
|
||||
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// A PDF that takes longer than the auto-hide delay to fetch would otherwise reveal its first
|
||||
// page with the chrome already gone, and nothing left to re-arm the timer.
|
||||
val controlsVisible =
|
||||
rememberViewerControlsVisibility(
|
||||
holdOpen = sharePopupExpanded.value,
|
||||
armed = handle != null,
|
||||
)
|
||||
|
||||
val pagerState = rememberPagerState { handle?.pageCount ?: 0 }
|
||||
val pageCache = remember(handle) { PageBitmapCache(PAGE_CACHE_SIZE) }
|
||||
|
||||
// The page counter is wayfinding rather than a control, so it outlives the buttons for a
|
||||
// moment after every page turn -- a reader who tapped the chrome away still sees where a
|
||||
// swipe landed.
|
||||
var pageJustChanged by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
pageJustChanged = true
|
||||
delay(PAGE_INDICATOR_FLASH_MS)
|
||||
pageJustChanged = false
|
||||
}
|
||||
|
||||
val toggleControls = { if (!sharePopupExpanded.value) controlsVisible.value = !controlsVisible.value }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().clickable(onClick = toggleControls)) {
|
||||
if (handle == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
} else if (handle.pageCount == 0) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = "Unable to open PDF",
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -252,53 +272,54 @@ private fun PdfViewerContent(
|
||||
handle = handle,
|
||||
pageIndex = pageIndex,
|
||||
cache = pageCache,
|
||||
// The zoomable page consumes the tap before the box underneath ever sees it,
|
||||
// so the toggle has to hang off the gesture detector that owns it.
|
||||
onTap = toggleControls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.systemBarsPadding()
|
||||
.padding(horizontal = Size15dp, vertical = Size10dp),
|
||||
horizontalArrangement = spacedBy(Size10dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
)
|
||||
// The buttons hold the top edge; the page counter lives along the bottom. Keeping them on
|
||||
// one strip meant the counter sat dead centre under the display cutout on punch-hole
|
||||
// devices, which is exactly where a front camera is. Down here it is clear of the cutout,
|
||||
// stays centred on the screen rather than on whatever space the asymmetric button groups
|
||||
// leave behind, and reads as wayfinding rather than as another control.
|
||||
ViewerControlsRow(modifier = Modifier.align(Alignment.TopCenter)) {
|
||||
AnimatedVisibility(visible = controlsVisible.value, enter = fadeIn(), exit = fadeOut()) {
|
||||
ViewerBackButton(onDismiss)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (handle != null) {
|
||||
AnimatedVisibility(visible = controlsVisible.value, enter = fadeIn(), exit = fadeOut()) {
|
||||
Row(horizontalArrangement = spacedBy(Size10dp)) {
|
||||
ViewerShareButton(content, sharePopupExpanded, accountViewModel)
|
||||
|
||||
ViewerSaveToGalleryButton(content, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
|
||||
color = Color.White,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
|
||||
.padding(horizontal = Size10dp, vertical = Size5dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { sharePopupExpanded.value = true },
|
||||
contentPadding = PaddingValues(horizontal = Size5dp),
|
||||
colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background),
|
||||
if (handle != null && handle.pageCount > 0) {
|
||||
ViewerControlsRow(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
atBottom = true,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = controlsVisible.value || pageJustChanged,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.quick_action_share),
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1} / ${handle.pageCount}",
|
||||
color = Color.White,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.Black.copy(alpha = 0.4f), shape = MaterialTheme.shapes.small)
|
||||
.padding(horizontal = Size10dp, vertical = Size5dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -312,6 +333,7 @@ private fun PdfPageView(
|
||||
handle: PdfDocumentHandle,
|
||||
pageIndex: Int,
|
||||
cache: PageBitmapCache,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val cached = cache.get(pageIndex)
|
||||
|
||||
@@ -373,6 +395,7 @@ private fun PdfPageView(
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
zoomState = zoomState,
|
||||
onTap = { onTap() },
|
||||
onDoubleTap = { position ->
|
||||
zoomState.toggleScale(targetScale = DOUBLE_TAP_ZOOM_SCALE, position = position)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user