diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FullScreenViewerChrome.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FullScreenViewerChrome.kt new file mode 100644 index 0000000000..f6d8aa4e57 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FullScreenViewerChrome.kt @@ -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 { + 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, + 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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt index 4afbc68666..9808f211b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -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), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 4239c9be68..50698249d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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, 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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt index 75fe452184..93c3342ee3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfViewerDialog.kt @@ -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) },