From c94c882c3a80702b4811f5377baa8b0d7ed98779 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 6 Jun 2024 18:39:39 -0400 Subject: [PATCH] restructures video and image views to avoid buttons getting on top of one another. --- .../amethyst/ui/actions/EditPostView.kt | 1 + .../amethyst/ui/actions/NewMediaView.kt | 1 + .../amethyst/ui/actions/NewPostView.kt | 3 +- .../amethyst/ui/components/VideoView.kt | 172 ++++-- .../ui/components/ZoomableContentDialog.kt | 380 +++++++++++++ .../ui/components/ZoomableContentView.kt | 510 +++++------------- .../amethyst/ui/note/types/AudioTrack.kt | 3 + .../amethyst/ui/note/types/FileHeader.kt | 3 + .../amethyst/ui/note/types/LiveActivity.kt | 1 + .../amethyst/ui/note/types/Video.kt | 3 + .../amethyst/ui/note/types/VideoDisplay.kt | 3 + .../ui/screen/loggedIn/AccountViewModel.kt | 10 + .../vitorpamplona/amethyst/ui/theme/Shape.kt | 1 + app/src/main/res/values/strings.xml | 2 + .../commons/richtext/MediaContentModels.kt | 7 +- .../commons/richtext/RichTextParser.kt | 2 + 16 files changed, 691 insertions(+), 411 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index ff3ae9966..82dacec41 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -304,6 +304,7 @@ fun EditPostView( } else if (RichTextParser.isVideoUrl(myUrlPreview)) { VideoView( myUrlPreview, + mimeType = null, roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 63f379ec3..0f94874f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -251,6 +251,7 @@ fun ImageVideoPost( postViewModel.galleryUri?.let { VideoView( videoUri = it.toString(), + mimeType = postViewModel.mediaType, roundedCorner = false, isFiniteHeight = false, accountViewModel = accountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 4734f49d4..0cfe1e6e3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -425,6 +425,7 @@ fun NewPostView( } else if (RichTextParser.isVideoUrl(myUrlPreview)) { VideoView( myUrlPreview, + mimeType = null, roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel, @@ -1737,7 +1738,7 @@ fun ImageVideoDescription( ) } } else { - VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel) + VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, mimeType = mediaType, accountViewModel = accountViewModel) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 621e24ab1..3d6c7a33e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -43,16 +43,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableFloatState import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -72,7 +77,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle @@ -85,8 +90,10 @@ import androidx.media3.session.MediaController import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import com.linc.audiowaveform.infiniteLinearGradient +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.produceCachedState +import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.service.playback.PlaybackClientController import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.LyricsIcon @@ -95,8 +102,11 @@ import com.vitorpamplona.amethyst.ui.note.MuteIcon import com.vitorpamplona.amethyst.ui.note.MutedIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize +import com.vitorpamplona.amethyst.ui.theme.Size110dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size75dp import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.imageModifier @@ -117,6 +127,7 @@ public val DEFAULT_MUTED_SETTING = mutableStateOf(true) @Composable fun LoadThumbAndThenVideoView( videoUri: String, + mimeType: String?, title: String? = null, thumbUri: String, authorName: String? = null, @@ -134,11 +145,12 @@ fun LoadThumbAndThenVideoView( context, thumbUri, onReady = { - if (it != null) { - loadingFinished = Pair(true, it) - } else { - loadingFinished = Pair(true, null) - } + loadingFinished = + if (it != null) { + Pair(true, it) + } else { + Pair(true, null) + } }, onError = { loadingFinished = Pair(true, null) }, ) @@ -148,6 +160,7 @@ fun LoadThumbAndThenVideoView( if (loadingFinished.second != null) { VideoView( videoUri = videoUri, + mimeType = mimeType, title = title, thumb = VideoThumb(loadingFinished.second), roundedCorner = roundedCorner, @@ -161,6 +174,7 @@ fun LoadThumbAndThenVideoView( } else { VideoView( videoUri = videoUri, + mimeType = mimeType, title = title, thumb = null, roundedCorner = roundedCorner, @@ -178,11 +192,11 @@ fun LoadThumbAndThenVideoView( @Composable fun VideoView( videoUri: String, + mimeType: String?, title: String? = null, thumb: VideoThumb? = null, roundedCorner: Boolean, isFiniteHeight: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, waveform: ImmutableList? = null, artworkUri: String? = null, authorName: String? = null, @@ -218,21 +232,20 @@ fun VideoView( } else { VideoViewInner( videoUri = videoUri, + mimeType = mimeType, defaultToStart = defaultToStart, title = title, thumb = thumb, roundedCorner = roundedCorner, isFiniteHeight = isFiniteHeight, - topPaddingForControllers = topPaddingForControllers, waveform = waveform, artworkUri = artworkUri, authorName = authorName, - dimensions = dimensions, - blurhash = blurhash, nostrUriCallback = nostrUriCallback, automaticallyStartPlayback = automaticallyStartPlayback, onControllerVisibilityChanged = onControllerVisibilityChanged, onDialog = onDialog, + accountViewModel = accountViewModel, ) } } @@ -272,21 +285,20 @@ fun VideoView( } else { VideoViewInner( videoUri = videoUri, + mimeType = mimeType, defaultToStart = defaultToStart, title = title, thumb = thumb, roundedCorner = roundedCorner, isFiniteHeight = isFiniteHeight, - topPaddingForControllers = topPaddingForControllers, waveform = waveform, artworkUri = artworkUri, authorName = authorName, - dimensions = dimensions, - blurhash = blurhash, nostrUriCallback = nostrUriCallback, automaticallyStartPlayback = automaticallyStartPlayback, onControllerVisibilityChanged = onControllerVisibilityChanged, onDialog = onDialog, + accountViewModel = accountViewModel, ) } } @@ -297,21 +309,20 @@ fun VideoView( @OptIn(androidx.media3.common.util.UnstableApi::class) fun VideoViewInner( videoUri: String, + mimeType: String?, defaultToStart: Boolean = false, title: String? = null, thumb: VideoThumb? = null, roundedCorner: Boolean, isFiniteHeight: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, waveform: ImmutableList? = null, artworkUri: String? = null, authorName: String? = null, - dimensions: String? = null, - blurhash: String? = null, nostrUriCallback: String? = null, automaticallyStartPlayback: State, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, onDialog: ((Boolean) -> Unit)? = null, + accountViewModel: AccountViewModel, ) { VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen -> GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem -> @@ -322,13 +333,13 @@ fun VideoViewInner( nostrUriCallback = nostrUriCallback, ) { controller, keepPlaying -> RenderVideoPlayer( + videoUri = videoUri, + mimeType = mimeType, controller = controller, thumbData = thumb, roundedCorner = roundedCorner, isFiniteHeight = isFiniteHeight, - dimensions = dimensions, - blurhash = blurhash, - topPaddingForControllers = topPaddingForControllers, + nostrUriCallback = nostrUriCallback, waveform = waveform, keepPlaying = keepPlaying, automaticallyStartPlayback = automaticallyStartPlayback, @@ -336,6 +347,7 @@ fun VideoViewInner( modifier = modifier, onControllerVisibilityChanged = onControllerVisibilityChanged, onDialog = onDialog, + accountViewModel = accountViewModel, ) } } @@ -353,18 +365,18 @@ data class MediaItemData( ) class MediaItemCache() : GenericBaseCache(20) { - override suspend fun compute(data: MediaItemData): MediaItem? { + override suspend fun compute(key: MediaItemData): MediaItem { return MediaItem.Builder() - .setMediaId(data.videoUri) - .setUri(data.videoUri) + .setMediaId(key.videoUri) + .setUri(key.videoUri) .setMediaMetadata( MediaMetadata.Builder() - .setArtist(data.authorName?.ifBlank { null }) - .setTitle(data.title?.ifBlank { null } ?: data.videoUri) + .setArtist(key.authorName?.ifBlank { null }) + .setTitle(key.title?.ifBlank { null } ?: key.videoUri) .setArtworkUri( try { - if (data.artworkUri != null) { - Uri.parse(data.artworkUri) + if (key.artworkUri != null) { + Uri.parse(key.artworkUri) } else { null } @@ -647,13 +659,13 @@ data class VideoThumb( @Composable @OptIn(UnstableApi::class) private fun RenderVideoPlayer( + videoUri: String, + mimeType: String?, controller: MediaController, thumbData: VideoThumb?, roundedCorner: Boolean, isFiniteHeight: Boolean, - dimensions: String? = null, - blurhash: String? = null, - topPaddingForControllers: Dp = Dp.Unspecified, + nostrUriCallback: String?, waveform: ImmutableList? = null, keepPlaying: MutableState, automaticallyStartPlayback: State, @@ -661,6 +673,7 @@ private fun RenderVideoPlayer( modifier: Modifier, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, onDialog: ((Boolean) -> Unit)?, + accountViewModel: AccountViewModel, ) { ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen) @@ -724,6 +737,7 @@ private fun RenderVideoPlayer( MuteButton( controllerVisible, startingMuteState, + Modifier.align(Alignment.TopEnd), ) { mute: Boolean -> // makes the new setting the default for new creations. DEFAULT_MUTED_SETTING.value = mute @@ -741,7 +755,7 @@ private fun RenderVideoPlayer( KeepPlayingButton( keepPlaying, controllerVisible, - Modifier.align(Alignment.TopEnd), + Modifier.align(Alignment.TopEnd).padding(end = Size55dp), ) { newKeepPlaying: Boolean -> // If something else is playing and the user marks this video to keep playing, stops the other // one. @@ -759,6 +773,15 @@ private fun RenderVideoPlayer( keepPlaying.value = newKeepPlaying } + + AnimatedSaveAndShareButton( + videoUri = videoUri, + mimeType = mimeType, + nostrUriCallback = nostrUriCallback, + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.TopEnd).padding(end = Size110dp), + accountViewModel = accountViewModel, + ) } } @@ -777,7 +800,7 @@ fun Waveform( controller: MediaController, modifier: Modifier, ) { - val waveformProgress = remember { mutableStateOf(0F) } + val waveformProgress = remember { mutableFloatStateOf(0F) } DrawWaveform(waveform, waveformProgress, modifier) @@ -791,7 +814,7 @@ fun Waveform( // doesn't consider the mutex because the screen can turn off if the video // being played in the mutex is not visible. if (isPlaying) { - restartFlow.value += 1 + restartFlow.intValue += 1 } } } @@ -800,28 +823,28 @@ fun Waveform( onDispose { controller.removeListener(listener) } } - LaunchedEffect(key1 = restartFlow.value) { - pollCurrentDuration(controller).collect { value -> waveformProgress.value = value } + LaunchedEffect(key1 = restartFlow.intValue) { + pollCurrentDuration(controller).collect { value -> waveformProgress.floatValue = value } } } @Composable fun DrawWaveform( waveform: ImmutableList, - waveformProgress: MutableState, + waveformProgress: MutableFloatState, modifier: Modifier, ) { AudioWaveformReadOnly( modifier = modifier.padding(start = 10.dp, end = 10.dp), amplitudes = waveform, - progress = waveformProgress.value, + progress = waveformProgress.floatValue, progressBrush = Brush.infiniteLinearGradient( colors = listOf(Color(0xff2598cf), Color(0xff652d80)), animation = tween(durationMillis = 6000, easing = LinearEasing), width = 128F, ), - onProgressChange = { waveformProgress.value = it }, + onProgressChange = { waveformProgress.floatValue = it }, ) } @@ -926,6 +949,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? { private fun MuteButton( controllerVisible: MutableState, startingMuteState: Boolean, + modifier: Modifier, toggle: (Boolean) -> Unit, ) { val holdOn = @@ -946,6 +970,7 @@ private fun MuteButton( AnimatedVisibility( visible = holdOn.value || controllerVisible.value, + modifier = modifier, enter = remember { fadeIn() }, exit = remember { fadeOut() }, ) { @@ -978,14 +1003,14 @@ private fun MuteButton( private fun KeepPlayingButton( keepPlayingStart: MutableState, controllerVisible: MutableState, - alignment: Modifier, + modifier: Modifier, toggle: (Boolean) -> Unit, ) { val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) } AnimatedVisibility( visible = controllerVisible.value, - modifier = alignment, + modifier = modifier, enter = remember { fadeIn() }, exit = remember { fadeOut() }, ) { @@ -1013,3 +1038,72 @@ private fun KeepPlayingButton( } } } + +@Composable +fun AnimatedSaveAndShareButton( + videoUri: String, + mimeType: String?, + nostrUriCallback: String?, + controllerVisible: State, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + AnimatedSaveAndShareButton(controllerVisible, modifier) { popupExpanded, toggle -> + ShareImageAction(popupExpanded, videoUri, nostrUriCallback, mimeType, toggle, accountViewModel) + } +} + +@Composable +fun SaveAndShareButton( + content: BaseMediaContent, + accountViewModel: AccountViewModel, +) { + SaveAndShareButton { popupExpanded, toggle -> + ShareImageAction(popupExpanded, content, toggle, accountViewModel) + } +} + +@Composable +fun AnimatedSaveAndShareButton( + controllerVisible: State, + modifier: Modifier, + innerAction: @Composable (MutableState, () -> Unit) -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + SaveAndShareButton(innerAction) + } +} + +@Composable +fun SaveAndShareButton(innerAction: @Composable (MutableState, () -> Unit) -> Unit) { + Box(modifier = PinBottomIconSize) { + Box( + Modifier.clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + val popupExpanded = remember { mutableStateOf(false) } + + IconButton( + onClick = { + popupExpanded.value = true + }, + modifier = Size50Modifier, + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringResource(R.string.share_or_save), + ) + + innerAction(popupExpanded) { popupExpanded.value = false } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt new file mode 100644 index 000000000..dfa2d921f --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -0,0 +1,380 @@ +/** + * Copyright (c) 2024 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.os.Build +import android.view.View +import android.view.WindowInsets +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi +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.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.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +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.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent +import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage +import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable + +@Composable +fun ZoomableImageDialog( + imageUrl: BaseMediaContent, + allImages: ImmutableList = listOf(imageUrl).toImmutableList(), + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + val orientation = LocalConfiguration.current.orientation + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = true, + decorFitsSystemWindows = false, + ), + ) { + val view = LocalView.current + val insets = ViewCompat.getRootWindowInsets(view) + + val orientation = LocalConfiguration.current.orientation + println("This Log only exists to force orientation listener $orientation") + + val activityWindow = getActivityWindow() + val dialogWindow = getDialogWindow() + val parentView = LocalView.current.parent as View + SideEffect { + if (activityWindow != null && dialogWindow != null) { + val attributes = WindowManager.LayoutParams() + attributes.copyFrom(activityWindow.attributes) + attributes.type = dialogWindow.attributes.type + dialogWindow.attributes = attributes + parentView.layoutParams = + FrameLayout.LayoutParams( + activityWindow.decorView.width, + activityWindow.decorView.height, + ) + view.layoutParams = + FrameLayout.LayoutParams( + activityWindow.decorView.width, + activityWindow.decorView.height, + ) + } + } + + DisposableEffect(key1 = Unit) { + if (Build.VERSION.SDK_INT >= 30) { + view.windowInsetsController?.hide( + WindowInsets.Type.systemBars(), + ) + } + + onDispose { + if (Build.VERSION.SDK_INT >= 30) { + view.windowInsetsController?.show( + WindowInsets.Type.systemBars(), + ) + } + } + } + + Surface(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + DialogContent(allImages, imageUrl, onDismiss, accountViewModel) + } + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun DialogContent( + allImages: ImmutableList, + imageUrl: BaseMediaContent, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + val pagerState: PagerState = rememberPagerState { allImages.size } + val controllerVisible = remember { mutableStateOf(true) } + + LaunchedEffect(key1 = pagerState, key2 = imageUrl) { + launch { + val page = allImages.indexOf(imageUrl) + if (page > -1) { + pagerState.scrollToPage(page) + } + } + launch(Dispatchers.Default) { + delay(2000) + withContext(Dispatchers.Main) { + controllerVisible.value = false + } + } + } + + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + ) { index -> + allImages.getOrNull(index)?.let { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RenderImageOrVideo( + content = it, + roundedCorner = false, + isFiniteHeight = true, + controllerVisible = controllerVisible, + onControllerVisibilityChanged = { controllerVisible.value = it }, + onToggleControllerVisibility = { + controllerVisible.value = !controllerVisible.value + }, + accountViewModel = accountViewModel, + ) + } + } + } + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RenderImageOrVideo( + content = imageUrl, + roundedCorner = false, + isFiniteHeight = true, + controllerVisible = controllerVisible, + onControllerVisibilityChanged = { controllerVisible.value = it }, + onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, + accountViewModel = accountViewModel, + ) + } + } + + AnimatedVisibility( + visible = controllerVisible.value, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Row( + modifier = Modifier.padding(Size10dp).statusBarsPadding().systemBarsPadding().fillMaxWidth(), + horizontalArrangement = spacedBy(Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + ArrowBackIcon() + } + + val popupExpanded = remember { mutableStateOf(false) } + + OutlinedButton( + onClick = { popupExpanded.value = true }, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringResource(R.string.share_or_save), + ) + + allImages.getOrNull(pagerState.currentPage)?.let { myContent -> + ShareImageAction(popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }, accountViewModel = accountViewModel) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun InlineCarrousel( + allImages: ImmutableList, + imageUrl: String, +) { + val pagerState: PagerState = rememberPagerState { allImages.size } + + LaunchedEffect(key1 = pagerState, key2 = imageUrl) { + launch { + val page = allImages.indexOf(imageUrl) + if (page > -1) { + pagerState.scrollToPage(page) + } + } + } + + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + ) { index -> + AsyncImage( + model = allImages[index], + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun RenderImageOrVideo( + content: BaseMediaContent, + roundedCorner: Boolean, + isFiniteHeight: Boolean, + controllerVisible: MutableState, + onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + onToggleControllerVisibility: (() -> Unit)? = null, + accountViewModel: AccountViewModel, +) { + val automaticallyStartPlayback = remember { mutableStateOf(true) } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { + if (content is MediaUrlImage) { + val mainModifier = + Modifier + .fillMaxWidth() + .zoomable( + rememberZoomState(), + onTap = { + if (onToggleControllerVisibility != null) { + onToggleControllerVisibility() + } + }, + ) + + UrlImageView( + content = content, + mainImageModifier = mainModifier, + isFiniteHeight = isFiniteHeight, + controllerVisible = controllerVisible, + accountViewModel = accountViewModel, + alwayShowImage = true, + ) + } else if (content is MediaUrlVideo) { + VideoViewInner( + videoUri = content.url, + mimeType = content.mimeType, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + roundedCorner = roundedCorner, + isFiniteHeight = isFiniteHeight, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + accountViewModel = accountViewModel, + ) + } else if (content is MediaLocalImage) { + val mainModifier = + Modifier + .fillMaxWidth() + .zoomable( + rememberZoomState(), + onTap = { + if (onToggleControllerVisibility != null) { + onToggleControllerVisibility() + } + }, + ) + + LocalImageView( + content = content, + mainImageModifier = mainModifier, + isFiniteHeight = isFiniteHeight, + controllerVisible = controllerVisible, + accountViewModel = accountViewModel, + alwayShowImage = true, + ) + } else if (content is MediaLocalVideo) { + content.localFile?.let { + VideoViewInner( + videoUri = it.toUri().toString(), + mimeType = content.mimeType, + title = content.description, + artworkUri = content.artworkUri, + authorName = content.authorName, + roundedCorner = roundedCorner, + isFiniteHeight = isFiniteHeight, + automaticallyStartPlayback = automaticallyStartPlayback, + onControllerVisibilityChanged = onControllerVisibilityChanged, + accountViewModel = accountViewModel, + ) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 0998e5347..9232f66df 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -20,53 +20,32 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.Manifest import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.os.Build import android.util.Log -import android.view.View import android.view.Window -import android.view.WindowManager -import android.widget.FrameLayout import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -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.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Share import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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 -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -76,7 +55,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView @@ -87,19 +65,18 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.net.toFile import androidx.core.net.toUri -import androidx.core.view.ViewCompat import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImageContent +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.richtext.BaseMediaContent @@ -110,24 +87,19 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.service.BlurHashRequester -import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.ImageSaver import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation -import com.vitorpamplona.amethyst.ui.actions.SaveToGallery import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon import com.vitorpamplona.amethyst.ui.note.HashCheckIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Font17SP -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size30dp -import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.Size75dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.crypto.CryptoUtils @@ -139,11 +111,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import net.engawapg.lib.zoomable.rememberZoomState -import net.engawapg.lib.zoomable.zoomable @Composable -@OptIn(ExperimentalFoundationApi::class) fun ZoomableContentView( content: BaseMediaContent, images: ImmutableList = remember(content) { listOf(content).toImmutableList() }, @@ -154,13 +123,6 @@ fun ZoomableContentView( // store the dialog open or close state var dialogOpen by remember(content) { mutableStateOf(false) } - // store the dialog open or close state - val shareOpen = remember { mutableStateOf(false) } - - if (shareOpen.value) { - ShareImageAction(shareOpen, content) { shareOpen.value = false } - } - var mainImageModifier = if (roundedCorner) { MaterialTheme.colorScheme.imageModifier @@ -170,30 +132,31 @@ fun ZoomableContentView( if (content is MediaUrlContent) { mainImageModifier = - mainImageModifier.combinedClickable( + mainImageModifier.clickable( onClick = { dialogOpen = true }, - onLongClick = { shareOpen.value = true }, ) } else if (content is MediaPreloadedContent) { mainImageModifier = - mainImageModifier.combinedClickable( + mainImageModifier.clickable( onClick = { dialogOpen = true }, - onLongClick = { shareOpen.value = true }, ) } else { mainImageModifier = mainImageModifier.clickable { dialogOpen = true } } + val controllerVisible = remember { mutableStateOf(true) } + when (content) { is MediaUrlImage -> SensitivityWarning(content.contentWarning != null, accountViewModel) { - UrlImageView(content, mainImageModifier, isFiniteHeight, accountViewModel = accountViewModel) + UrlImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel) } is MediaUrlVideo -> SensitivityWarning(content.contentWarning != null, accountViewModel) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { VideoView( videoUri = content.url, + mimeType = content.mimeType, title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, @@ -208,12 +171,13 @@ fun ZoomableContentView( } } is MediaLocalImage -> - LocalImageView(content, mainImageModifier, isFiniteHeight, accountViewModel = accountViewModel) + LocalImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel) is MediaLocalVideo -> content.localFile?.let { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { VideoView( videoUri = it.toUri().toString(), + mimeType = content.mimeType, title = content.description, artworkUri = content.artworkUri, authorName = content.authorName, @@ -233,11 +197,11 @@ fun ZoomableContentView( } @Composable -private fun LocalImageView( +fun LocalImageView( content: MediaLocalImage, mainImageModifier: Modifier, isFiniteHeight: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, + controllerVisible: MutableState, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, ) { @@ -294,15 +258,15 @@ private fun LocalImageView( SubcomposeAsyncImageContent() content.isVerified?.let { - val verifierModifier = - if (topPaddingForControllers.isSpecified) { - Modifier.align(Alignment.TopEnd).padding(top = topPaddingForControllers) - } else { - Modifier.align(Alignment.TopEnd) + AnimatedVisibility( + visible = controllerVisible.value, + modifier = Modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(Modifier.align(Alignment.TopEnd), contentAlignment = Alignment.TopEnd) { + HashVerificationSymbol(it) } - - Box(verifierModifier, contentAlignment = Alignment.TopEnd) { - HashVerificationSymbol(it) } } } @@ -336,11 +300,11 @@ private fun LocalImageView( } @Composable -private fun UrlImageView( +fun UrlImageView( content: MediaUrlImage, mainImageModifier: Modifier, isFiniteHeight: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, + controllerVisible: MutableState, accountViewModel: AccountViewModel, alwayShowImage: Boolean = false, ) { @@ -361,7 +325,7 @@ private fun UrlImageView( model = content.url, contentDescription = content.description, contentScale = contentScale, - modifier = mainImageModifier, + modifier = mainImageModifier.border(10.dp, Color.Red), ) { when (painter.state) { is AsyncImagePainter.State.Loading, @@ -392,15 +356,15 @@ private fun UrlImageView( is AsyncImagePainter.State.Success -> { SubcomposeAsyncImageContent() - val verifierModifier = - if (topPaddingForControllers.isSpecified) { - Modifier.align(Alignment.TopEnd).padding(top = topPaddingForControllers) - } else { - Modifier.align(Alignment.TopEnd) + AnimatedVisibility( + visible = controllerVisible.value, + modifier = Modifier.align(Alignment.TopEnd), + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(Modifier.align(Alignment.TopEnd), contentAlignment = Alignment.TopEnd) { + ShowHash(content) } - - Box(verifierModifier, contentAlignment = Alignment.TopEnd) { - ShowHash(content) } } else -> {} @@ -634,217 +598,42 @@ fun DisplayBlurHash( } @Composable -fun ZoomableImageDialog( - imageUrl: BaseMediaContent, - allImages: ImmutableList = listOf(imageUrl).toImmutableList(), - onDismiss: () -> Unit, - accountViewModel: AccountViewModel, -) { - val orientation = LocalConfiguration.current.orientation - - Dialog( - onDismissRequest = onDismiss, - properties = - DialogProperties( - usePlatformDefaultWidth = true, - decorFitsSystemWindows = false, - ), - ) { - val view = LocalView.current - val insets = ViewCompat.getRootWindowInsets(view) - - val orientation = LocalConfiguration.current.orientation - println("This Log only exists to force orientation listener $orientation") - - val activityWindow = getActivityWindow() - val dialogWindow = getDialogWindow() - val parentView = LocalView.current.parent as View - SideEffect { - if (activityWindow != null && dialogWindow != null) { - val attributes = WindowManager.LayoutParams() - attributes.copyFrom(activityWindow.attributes) - attributes.type = dialogWindow.attributes.type - dialogWindow.attributes = attributes - parentView.layoutParams = - FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) - view.layoutParams = - FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height) - } - } - - DisposableEffect(key1 = Unit) { - if (Build.VERSION.SDK_INT >= 30) { - view.windowInsetsController?.hide( - android.view.WindowInsets.Type.systemBars(), - ) - } - - onDispose { - if (Build.VERSION.SDK_INT >= 30) { - view.windowInsetsController?.show( - android.view.WindowInsets.Type.systemBars(), - ) - } - } - } - - Surface(modifier = Modifier.fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - DialogContent(allImages, imageUrl, onDismiss, accountViewModel) - } - } - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun DialogContent( - allImages: ImmutableList, - imageUrl: BaseMediaContent, - onDismiss: () -> Unit, - accountViewModel: AccountViewModel, -) { - val pagerState: PagerState = rememberPagerState { allImages.size } - val controllerVisible = remember { mutableStateOf(false) } - val holdOn = remember { mutableStateOf(true) } - - LaunchedEffect(key1 = pagerState, key2 = imageUrl) { - launch { - val page = allImages.indexOf(imageUrl) - if (page > -1) { - pagerState.scrollToPage(page) - } - } - launch(Dispatchers.Default) { - delay(2000) - holdOn.value = false - } - } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - ) { index -> - allImages.getOrNull(index)?.let { - RenderImageOrVideo( - content = it, - roundedCorner = false, - isFiniteHeight = true, - topPaddingForControllers = Size55dp, - onControllerVisibilityChanged = { controllerVisible.value = it }, - onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, - accountViewModel = accountViewModel, - ) - } - } - } else { - RenderImageOrVideo( - content = imageUrl, - roundedCorner = false, - isFiniteHeight = true, - topPaddingForControllers = Size55dp, - onControllerVisibilityChanged = { controllerVisible.value = it }, - onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, - accountViewModel = accountViewModel, - ) - } - - AnimatedVisibility( - visible = holdOn.value || controllerVisible.value, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, - ) { - Row( - modifier = - Modifier - .padding(10.dp) - .statusBarsPadding() - .systemBarsPadding() - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton(onPress = onDismiss) - - allImages.getOrNull(pagerState.currentPage)?.let { myContent -> - if (myContent is MediaUrlContent) { - Row { - CopyToClipboard(content = myContent) - Spacer(modifier = StdHorzSpacer) - SaveToGallery(url = myContent.url) - } - } else if (myContent is MediaLocalImage && myContent.localFileExists()) { - SaveToGallery( - localFile = myContent.localFile!!, - mimeType = myContent.mimeType, - ) - } - } - } - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -fun InlineCarrousel( - allImages: ImmutableList, - imageUrl: String, -) { - val pagerState: PagerState = rememberPagerState { allImages.size } - - LaunchedEffect(key1 = pagerState, key2 = imageUrl) { - launch { - val page = allImages.indexOf(imageUrl) - if (page > -1) { - pagerState.scrollToPage(page) - } - } - } - - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - ) { index -> - AsyncImage( - model = allImages[index], - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } - } else { - AsyncImage( - model = imageUrl, - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@Composable -private fun CopyToClipboard(content: BaseMediaContent) { - val popupExpanded = remember { mutableStateOf(false) } - - OutlinedButton( - modifier = Modifier.padding(horizontal = Size5dp), - onClick = { popupExpanded.value = true }, - ) { - Icon( - imageVector = Icons.Default.Share, - modifier = Size20Modifier, - contentDescription = stringResource(R.string.copy_url_to_clipboard), - ) - - ShareImageAction(popupExpanded, content) { popupExpanded.value = false } - } -} - -@Composable -private fun ShareImageAction( +fun ShareImageAction( popupExpanded: MutableState, content: BaseMediaContent, onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + if (content is MediaUrlContent) { + ShareImageAction( + popupExpanded = popupExpanded, + videoUri = content.url, + postNostrUri = content.uri, + mimeType = content.mimeType, + onDismiss = onDismiss, + accountViewModel = accountViewModel, + ) + } else if (content is MediaPreloadedContent) { + ShareImageAction( + popupExpanded = popupExpanded, + videoUri = content.localFile?.toUri().toString(), + postNostrUri = content.uri, + mimeType = content.mimeType, + onDismiss = onDismiss, + accountViewModel = accountViewModel, + ) + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun ShareImageAction( + popupExpanded: MutableState, + videoUri: String?, + postNostrUri: String?, + mimeType: String?, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, ) { DropdownMenu( expanded = popupExpanded.value, @@ -852,118 +641,101 @@ private fun ShareImageAction( ) { val clipboardManager = LocalClipboardManager.current - if (content is MediaUrlContent) { + if (videoUri != null && !videoUri.startsWith("file")) { DropdownMenuItem( text = { Text(stringResource(R.string.copy_url_to_clipboard)) }, onClick = { - clipboardManager.setText(AnnotatedString(content.url)) + clipboardManager.setText(AnnotatedString(videoUri)) onDismiss() }, ) - content.uri?.let { - DropdownMenuItem( - text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString(it)) - onDismiss() - }, - ) - } } - if (content is MediaPreloadedContent) { + postNostrUri?.let { DropdownMenuItem( text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, onClick = { - clipboardManager.setText(AnnotatedString(content.uri)) + clipboardManager.setText(AnnotatedString(it)) onDismiss() }, ) } - } -} -@Composable -private fun RenderImageOrVideo( - content: BaseMediaContent, - roundedCorner: Boolean, - isFiniteHeight: Boolean, - topPaddingForControllers: Dp = Dp.Unspecified, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onToggleControllerVisibility: (() -> Unit)? = null, - accountViewModel: AccountViewModel, -) { - val automaticallyStartPlayback = remember { mutableStateOf(true) } + if (videoUri != null) { + if (!videoUri.startsWith("file")) { + val localContext = LocalContext.current - if (content is MediaUrlImage) { - val mainModifier = - Modifier - .fillMaxSize() - .zoomable( - rememberZoomState(), - onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + url = videoUri, + onSuccess = { + accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) + }, + onError = { + accountViewModel.toast(R.string.failed_to_save_the_image, null, it) + }, + ) + } + + val writeStoragePermissionState = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> + if (isGranted) { + saveImage() } + } + + DropdownMenuItem( + text = { Text(stringResource(R.string.save_to_gallery)) }, + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + onDismiss() }, ) + } else { + val localContext = LocalContext.current - UrlImageView( - content = content, - mainImageModifier = mainModifier, - isFiniteHeight = isFiniteHeight, - topPaddingForControllers = topPaddingForControllers, - accountViewModel = accountViewModel, - alwayShowImage = true, - ) - } else if (content is MediaUrlVideo) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize(1f)) { - VideoViewInner( - videoUri = content.url, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, - topPaddingForControllers = topPaddingForControllers, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - ) - } - } else if (content is MediaLocalImage) { - val mainModifier = - Modifier - .fillMaxSize() - .zoomable( - rememberZoomState(), - onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() + fun saveImage() { + ImageSaver.saveImage( + context = localContext, + localFile = videoUri.toUri().toFile(), + mimeType = mimeType, + onSuccess = { + accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) + }, + onError = { + accountViewModel.toast(R.string.failed_to_save_the_image, null, it) + }, + ) + } + + val writeStoragePermissionState = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> + if (isGranted) { + saveImage() } - }, - ) + } - LocalImageView( - content = content, - mainImageModifier = mainModifier, - isFiniteHeight = isFiniteHeight, - topPaddingForControllers = topPaddingForControllers, - accountViewModel = accountViewModel, - alwayShowImage = true, - ) - } else if (content is MediaLocalVideo) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize(1f)) { - content.localFile?.let { - VideoViewInner( - videoUri = it.toUri().toString(), - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - roundedCorner = roundedCorner, - isFiniteHeight = isFiniteHeight, - topPaddingForControllers = topPaddingForControllers, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, + DropdownMenuItem( + text = { Text(stringResource(R.string.save_to_gallery)) }, + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + saveImage() + } else { + writeStoragePermissionState.launchPermissionRequest() + } + onDismiss() + }, ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt index db48f8f6e..1c59001dc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AudioTrack.kt @@ -142,6 +142,7 @@ fun AudioTrackHeader( cover?.let { cover -> LoadThumbAndThenVideoView( videoUri = media, + mimeType = null, title = noteEvent.subject(), thumbUri = cover, authorName = note.author?.toBestDisplayName(), @@ -153,6 +154,7 @@ fun AudioTrackHeader( } ?: run { VideoView( videoUri = media, + mimeType = null, title = noteEvent.subject(), authorName = note.author?.toBestDisplayName(), roundedCorner = true, @@ -202,6 +204,7 @@ fun AudioHeader( ) { VideoView( videoUri = media, + mimeType = null, waveform = waveform, title = noteEvent.subject(), authorName = note.author?.toBestDisplayName(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 12374a735..663f01d06 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -52,6 +52,7 @@ fun FileHeaderDisplay( val description = event.content.ifEmpty { null } ?: event.alt() val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() + val mimeType = event.mimeType() mutableStateOf( if (isImage) { @@ -62,6 +63,7 @@ fun FileHeaderDisplay( blurhash = blurHash, dim = dimensions, uri = uri, + mimeType = mimeType, ) } else { MediaUrlVideo( @@ -72,6 +74,7 @@ fun FileHeaderDisplay( dim = dimensions, uri = uri, authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, ) }, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index bc362d984..cd5ed6250 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -152,6 +152,7 @@ fun RenderLiveActivityEventInner( ) { VideoView( videoUri = media, + mimeType = null, title = subject, artworkUri = cover, authorName = baseNote.author?.toBestDisplayName(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index 563bb12c8..18636729a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -87,6 +87,7 @@ fun VideoDisplay( val description = event.content.ifBlank { null } ?: event.alt() val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() + val mimeType = event.mimeType() mutableStateOf( if (isImage) { @@ -97,6 +98,7 @@ fun VideoDisplay( blurhash = blurHash, dim = dimensions, uri = uri, + mimeType = mimeType, ) } else { MediaUrlVideo( @@ -107,6 +109,7 @@ fun VideoDisplay( uri = uri, authorName = note.author?.toBestDisplayName(), artworkUri = event.thumb() ?: event.image(), + mimeType = mimeType, ) }, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 1d3fec9b5..5cc2cdab3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -52,6 +52,7 @@ fun JustVideoDisplay( val description = event.content.ifEmpty { null } ?: event.alt() val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val uri = note.toNostrUri() + val mimeType = event.mimeType() mutableStateOf( if (isImage) { @@ -62,6 +63,7 @@ fun JustVideoDisplay( blurhash = blurHash, dim = dimensions, uri = uri, + mimeType = mimeType, ) } else { MediaUrlVideo( @@ -72,6 +74,7 @@ fun JustVideoDisplay( dim = dimensions, uri = uri, authorName = note.author?.toBestDisplayName(), + mimeType = mimeType, ) }, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index de0044be9..80acb0f91 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -125,6 +125,8 @@ import kotlin.time.measureTimedValue val params: Array? = null, ) : ToastMsg() +@Immutable class ThrowableToastMsg(val titleResId: Int, val msg: String? = null, val throwable: Throwable) : ToastMsg() + @Stable class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao { val accountLiveData: LiveData = account.live.map { it } @@ -163,6 +165,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) } } + fun toast( + titleResId: Int, + message: String?, + throwable: Throwable, + ) { + viewModelScope.launch { toasts.emit(ThrowableToastMsg(titleResId, message, throwable)) } + } + fun toast( titleResId: Int, resourceId: Int, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 250480db1..cb3b0af01 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -96,6 +96,7 @@ val Size35dp = 35.dp val Size40dp = 40.dp val Size55dp = 55.dp val Size75dp = 75.dp +val Size110dp = 110.dp val HalfEndPadding = Modifier.padding(end = 5.dp) val HalfStartPadding = Modifier.padding(start = 5.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22cf695d1..a97d32ea6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,7 @@ Website URL LN Address LN URL (outdated) + Save to Gallery Image saved to the gallery Failed to save the image Upload Image @@ -580,6 +581,7 @@ Copy to clipboard Copy npub to clipboard + Share or Save Copy URL to clipboard Copy Note ID to clipboard diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index cefd5932d..2db6e28db 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -38,6 +38,7 @@ abstract class MediaUrlContent( dim: String? = null, blurhash: String? = null, val uri: String? = null, + val mimeType: String? = null, ) : BaseMediaContent(description, dim, blurhash) @Immutable @@ -49,7 +50,8 @@ class MediaUrlImage( dim: String? = null, uri: String? = null, val contentWarning: String? = null, -) : MediaUrlContent(url, description, hash, dim, blurhash, uri) + mimeType: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @Immutable class MediaUrlVideo( @@ -62,7 +64,8 @@ class MediaUrlVideo( val authorName: String? = null, blurhash: String? = null, val contentWarning: String? = null, -) : MediaUrlContent(url, description, hash, dim, blurhash, uri) + mimeType: String? = null, +) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType) @Immutable abstract class MediaPreloadedContent( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 6b0238d00..772b4e71f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -61,6 +61,7 @@ class RichTextParser() { dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], contentWarning = frags["content-warning"] ?: tags["content-warning"], uri = callbackUri, + mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE], ) } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { val frags = Nip54InlineMetadata().parse(fullUrl) @@ -73,6 +74,7 @@ class RichTextParser() { dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], contentWarning = frags["content-warning"] ?: tags["content-warning"], uri = callbackUri, + mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE], ) } else { null