restructures video and image views to avoid buttons getting on top of one another.

This commit is contained in:
Vitor Pamplona
2024-06-06 18:39:39 -04:00
parent 673f632563
commit c94c882c3a
16 changed files with 691 additions and 411 deletions
@@ -304,6 +304,7 @@ fun EditPostView(
} else if (RichTextParser.isVideoUrl(myUrlPreview)) { } else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView( VideoView(
myUrlPreview, myUrlPreview,
mimeType = null,
roundedCorner = true, roundedCorner = true,
isFiniteHeight = false, isFiniteHeight = false,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -251,6 +251,7 @@ fun ImageVideoPost(
postViewModel.galleryUri?.let { postViewModel.galleryUri?.let {
VideoView( VideoView(
videoUri = it.toString(), videoUri = it.toString(),
mimeType = postViewModel.mediaType,
roundedCorner = false, roundedCorner = false,
isFiniteHeight = false, isFiniteHeight = false,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -425,6 +425,7 @@ fun NewPostView(
} else if (RichTextParser.isVideoUrl(myUrlPreview)) { } else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView( VideoView(
myUrlPreview, myUrlPreview,
mimeType = null,
roundedCorner = true, roundedCorner = true,
isFiniteHeight = false, isFiniteHeight = false,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -1737,7 +1738,7 @@ fun ImageVideoDescription(
) )
} }
} else { } else {
VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, accountViewModel = accountViewModel) VideoView(uri.toString(), roundedCorner = true, isFiniteHeight = false, mimeType = mediaType, accountViewModel = accountViewModel)
} }
} }
@@ -43,16 +43,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalView 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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -85,8 +90,10 @@ import androidx.media3.session.MediaController
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.linc.audiowaveform.infiniteLinearGradient import com.linc.audiowaveform.infiniteLinearGradient
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.produceCachedState 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.service.playback.PlaybackClientController
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.LyricsIcon 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.note.MutedIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize 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.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier 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.Size75dp
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier
@@ -117,6 +127,7 @@ public val DEFAULT_MUTED_SETTING = mutableStateOf(true)
@Composable @Composable
fun LoadThumbAndThenVideoView( fun LoadThumbAndThenVideoView(
videoUri: String, videoUri: String,
mimeType: String?,
title: String? = null, title: String? = null,
thumbUri: String, thumbUri: String,
authorName: String? = null, authorName: String? = null,
@@ -134,11 +145,12 @@ fun LoadThumbAndThenVideoView(
context, context,
thumbUri, thumbUri,
onReady = { onReady = {
if (it != null) { loadingFinished =
loadingFinished = Pair(true, it) if (it != null) {
} else { Pair(true, it)
loadingFinished = Pair(true, null) } else {
} Pair(true, null)
}
}, },
onError = { loadingFinished = Pair(true, null) }, onError = { loadingFinished = Pair(true, null) },
) )
@@ -148,6 +160,7 @@ fun LoadThumbAndThenVideoView(
if (loadingFinished.second != null) { if (loadingFinished.second != null) {
VideoView( VideoView(
videoUri = videoUri, videoUri = videoUri,
mimeType = mimeType,
title = title, title = title,
thumb = VideoThumb(loadingFinished.second), thumb = VideoThumb(loadingFinished.second),
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
@@ -161,6 +174,7 @@ fun LoadThumbAndThenVideoView(
} else { } else {
VideoView( VideoView(
videoUri = videoUri, videoUri = videoUri,
mimeType = mimeType,
title = title, title = title,
thumb = null, thumb = null,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
@@ -178,11 +192,11 @@ fun LoadThumbAndThenVideoView(
@Composable @Composable
fun VideoView( fun VideoView(
videoUri: String, videoUri: String,
mimeType: String?,
title: String? = null, title: String? = null,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
roundedCorner: Boolean, roundedCorner: Boolean,
isFiniteHeight: Boolean, isFiniteHeight: Boolean,
topPaddingForControllers: Dp = Dp.Unspecified,
waveform: ImmutableList<Int>? = null, waveform: ImmutableList<Int>? = null,
artworkUri: String? = null, artworkUri: String? = null,
authorName: String? = null, authorName: String? = null,
@@ -218,21 +232,20 @@ fun VideoView(
} else { } else {
VideoViewInner( VideoViewInner(
videoUri = videoUri, videoUri = videoUri,
mimeType = mimeType,
defaultToStart = defaultToStart, defaultToStart = defaultToStart,
title = title, title = title,
thumb = thumb, thumb = thumb,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight, isFiniteHeight = isFiniteHeight,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform, waveform = waveform,
artworkUri = artworkUri, artworkUri = artworkUri,
authorName = authorName, authorName = authorName,
dimensions = dimensions,
blurhash = blurhash,
nostrUriCallback = nostrUriCallback, nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback, automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged, onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog, onDialog = onDialog,
accountViewModel = accountViewModel,
) )
} }
} }
@@ -272,21 +285,20 @@ fun VideoView(
} else { } else {
VideoViewInner( VideoViewInner(
videoUri = videoUri, videoUri = videoUri,
mimeType = mimeType,
defaultToStart = defaultToStart, defaultToStart = defaultToStart,
title = title, title = title,
thumb = thumb, thumb = thumb,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight, isFiniteHeight = isFiniteHeight,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform, waveform = waveform,
artworkUri = artworkUri, artworkUri = artworkUri,
authorName = authorName, authorName = authorName,
dimensions = dimensions,
blurhash = blurhash,
nostrUriCallback = nostrUriCallback, nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback, automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged, onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog, onDialog = onDialog,
accountViewModel = accountViewModel,
) )
} }
} }
@@ -297,21 +309,20 @@ fun VideoView(
@OptIn(androidx.media3.common.util.UnstableApi::class) @OptIn(androidx.media3.common.util.UnstableApi::class)
fun VideoViewInner( fun VideoViewInner(
videoUri: String, videoUri: String,
mimeType: String?,
defaultToStart: Boolean = false, defaultToStart: Boolean = false,
title: String? = null, title: String? = null,
thumb: VideoThumb? = null, thumb: VideoThumb? = null,
roundedCorner: Boolean, roundedCorner: Boolean,
isFiniteHeight: Boolean, isFiniteHeight: Boolean,
topPaddingForControllers: Dp = Dp.Unspecified,
waveform: ImmutableList<Int>? = null, waveform: ImmutableList<Int>? = null,
artworkUri: String? = null, artworkUri: String? = null,
authorName: String? = null, authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null, nostrUriCallback: String? = null,
automaticallyStartPlayback: State<Boolean>, automaticallyStartPlayback: State<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)? = null, onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
) { ) {
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen -> VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem -> GetMediaItem(videoUri, title, artworkUri, authorName) { mediaItem ->
@@ -322,13 +333,13 @@ fun VideoViewInner(
nostrUriCallback = nostrUriCallback, nostrUriCallback = nostrUriCallback,
) { controller, keepPlaying -> ) { controller, keepPlaying ->
RenderVideoPlayer( RenderVideoPlayer(
videoUri = videoUri,
mimeType = mimeType,
controller = controller, controller = controller,
thumbData = thumb, thumbData = thumb,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight, isFiniteHeight = isFiniteHeight,
dimensions = dimensions, nostrUriCallback = nostrUriCallback,
blurhash = blurhash,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform, waveform = waveform,
keepPlaying = keepPlaying, keepPlaying = keepPlaying,
automaticallyStartPlayback = automaticallyStartPlayback, automaticallyStartPlayback = automaticallyStartPlayback,
@@ -336,6 +347,7 @@ fun VideoViewInner(
modifier = modifier, modifier = modifier,
onControllerVisibilityChanged = onControllerVisibilityChanged, onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog, onDialog = onDialog,
accountViewModel = accountViewModel,
) )
} }
} }
@@ -353,18 +365,18 @@ data class MediaItemData(
) )
class MediaItemCache() : GenericBaseCache<MediaItemData, MediaItem>(20) { class MediaItemCache() : GenericBaseCache<MediaItemData, MediaItem>(20) {
override suspend fun compute(data: MediaItemData): MediaItem? { override suspend fun compute(key: MediaItemData): MediaItem {
return MediaItem.Builder() return MediaItem.Builder()
.setMediaId(data.videoUri) .setMediaId(key.videoUri)
.setUri(data.videoUri) .setUri(key.videoUri)
.setMediaMetadata( .setMediaMetadata(
MediaMetadata.Builder() MediaMetadata.Builder()
.setArtist(data.authorName?.ifBlank { null }) .setArtist(key.authorName?.ifBlank { null })
.setTitle(data.title?.ifBlank { null } ?: data.videoUri) .setTitle(key.title?.ifBlank { null } ?: key.videoUri)
.setArtworkUri( .setArtworkUri(
try { try {
if (data.artworkUri != null) { if (key.artworkUri != null) {
Uri.parse(data.artworkUri) Uri.parse(key.artworkUri)
} else { } else {
null null
} }
@@ -647,13 +659,13 @@ data class VideoThumb(
@Composable @Composable
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
private fun RenderVideoPlayer( private fun RenderVideoPlayer(
videoUri: String,
mimeType: String?,
controller: MediaController, controller: MediaController,
thumbData: VideoThumb?, thumbData: VideoThumb?,
roundedCorner: Boolean, roundedCorner: Boolean,
isFiniteHeight: Boolean, isFiniteHeight: Boolean,
dimensions: String? = null, nostrUriCallback: String?,
blurhash: String? = null,
topPaddingForControllers: Dp = Dp.Unspecified,
waveform: ImmutableList<Int>? = null, waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>, keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: State<Boolean>, automaticallyStartPlayback: State<Boolean>,
@@ -661,6 +673,7 @@ private fun RenderVideoPlayer(
modifier: Modifier, modifier: Modifier,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)?, onDialog: ((Boolean) -> Unit)?,
accountViewModel: AccountViewModel,
) { ) {
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen) ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
@@ -724,6 +737,7 @@ private fun RenderVideoPlayer(
MuteButton( MuteButton(
controllerVisible, controllerVisible,
startingMuteState, startingMuteState,
Modifier.align(Alignment.TopEnd),
) { mute: Boolean -> ) { mute: Boolean ->
// makes the new setting the default for new creations. // makes the new setting the default for new creations.
DEFAULT_MUTED_SETTING.value = mute DEFAULT_MUTED_SETTING.value = mute
@@ -741,7 +755,7 @@ private fun RenderVideoPlayer(
KeepPlayingButton( KeepPlayingButton(
keepPlaying, keepPlaying,
controllerVisible, controllerVisible,
Modifier.align(Alignment.TopEnd), Modifier.align(Alignment.TopEnd).padding(end = Size55dp),
) { newKeepPlaying: Boolean -> ) { newKeepPlaying: Boolean ->
// If something else is playing and the user marks this video to keep playing, stops the other // If something else is playing and the user marks this video to keep playing, stops the other
// one. // one.
@@ -759,6 +773,15 @@ private fun RenderVideoPlayer(
keepPlaying.value = newKeepPlaying 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, controller: MediaController,
modifier: Modifier, modifier: Modifier,
) { ) {
val waveformProgress = remember { mutableStateOf(0F) } val waveformProgress = remember { mutableFloatStateOf(0F) }
DrawWaveform(waveform, waveformProgress, modifier) DrawWaveform(waveform, waveformProgress, modifier)
@@ -791,7 +814,7 @@ fun Waveform(
// doesn't consider the mutex because the screen can turn off if the video // doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible. // being played in the mutex is not visible.
if (isPlaying) { if (isPlaying) {
restartFlow.value += 1 restartFlow.intValue += 1
} }
} }
} }
@@ -800,28 +823,28 @@ fun Waveform(
onDispose { controller.removeListener(listener) } onDispose { controller.removeListener(listener) }
} }
LaunchedEffect(key1 = restartFlow.value) { LaunchedEffect(key1 = restartFlow.intValue) {
pollCurrentDuration(controller).collect { value -> waveformProgress.value = value } pollCurrentDuration(controller).collect { value -> waveformProgress.floatValue = value }
} }
} }
@Composable @Composable
fun DrawWaveform( fun DrawWaveform(
waveform: ImmutableList<Int>, waveform: ImmutableList<Int>,
waveformProgress: MutableState<Float>, waveformProgress: MutableFloatState,
modifier: Modifier, modifier: Modifier,
) { ) {
AudioWaveformReadOnly( AudioWaveformReadOnly(
modifier = modifier.padding(start = 10.dp, end = 10.dp), modifier = modifier.padding(start = 10.dp, end = 10.dp),
amplitudes = waveform, amplitudes = waveform,
progress = waveformProgress.value, progress = waveformProgress.floatValue,
progressBrush = progressBrush =
Brush.infiniteLinearGradient( Brush.infiniteLinearGradient(
colors = listOf(Color(0xff2598cf), Color(0xff652d80)), colors = listOf(Color(0xff2598cf), Color(0xff652d80)),
animation = tween(durationMillis = 6000, easing = LinearEasing), animation = tween(durationMillis = 6000, easing = LinearEasing),
width = 128F, width = 128F,
), ),
onProgressChange = { waveformProgress.value = it }, onProgressChange = { waveformProgress.floatValue = it },
) )
} }
@@ -926,6 +949,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
private fun MuteButton( private fun MuteButton(
controllerVisible: MutableState<Boolean>, controllerVisible: MutableState<Boolean>,
startingMuteState: Boolean, startingMuteState: Boolean,
modifier: Modifier,
toggle: (Boolean) -> Unit, toggle: (Boolean) -> Unit,
) { ) {
val holdOn = val holdOn =
@@ -946,6 +970,7 @@ private fun MuteButton(
AnimatedVisibility( AnimatedVisibility(
visible = holdOn.value || controllerVisible.value, visible = holdOn.value || controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() }, enter = remember { fadeIn() },
exit = remember { fadeOut() }, exit = remember { fadeOut() },
) { ) {
@@ -978,14 +1003,14 @@ private fun MuteButton(
private fun KeepPlayingButton( private fun KeepPlayingButton(
keepPlayingStart: MutableState<Boolean>, keepPlayingStart: MutableState<Boolean>,
controllerVisible: MutableState<Boolean>, controllerVisible: MutableState<Boolean>,
alignment: Modifier, modifier: Modifier,
toggle: (Boolean) -> Unit, toggle: (Boolean) -> Unit,
) { ) {
val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) } val keepPlaying = remember(keepPlayingStart.value) { mutableStateOf(keepPlayingStart.value) }
AnimatedVisibility( AnimatedVisibility(
visible = controllerVisible.value, visible = controllerVisible.value,
modifier = alignment, modifier = modifier,
enter = remember { fadeIn() }, enter = remember { fadeIn() },
exit = remember { fadeOut() }, exit = remember { fadeOut() },
) { ) {
@@ -1013,3 +1038,72 @@ private fun KeepPlayingButton(
} }
} }
} }
@Composable
fun AnimatedSaveAndShareButton(
videoUri: String,
mimeType: String?,
nostrUriCallback: String?,
controllerVisible: State<Boolean>,
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<Boolean>,
modifier: Modifier,
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
) {
AnimatedVisibility(
visible = controllerVisible.value,
modifier = modifier,
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
SaveAndShareButton(innerAction)
}
}
@Composable
fun SaveAndShareButton(innerAction: @Composable (MutableState<Boolean>, () -> 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 }
}
}
}
@@ -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<BaseMediaContent> = 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<BaseMediaContent>,
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<String>,
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<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onToggleControllerVisibility: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(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,
)
}
}
}
}
@@ -20,53 +20,32 @@
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.view.View
import android.view.Window import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent 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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -76,7 +55,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView 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.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
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.compose.ui.window.DialogWindowProvider
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent 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.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent 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.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.service.BlurHashRequester 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.InformationDialog
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation 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.BlankNote
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
import com.vitorpamplona.amethyst.ui.note.HashCheckIcon import com.vitorpamplona.amethyst.ui.note.HashCheckIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font17SP 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.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size30dp 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.Size75dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark import com.vitorpamplona.amethyst.ui.theme.hashVerifierMark
import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.CryptoUtils
@@ -139,11 +111,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView( fun ZoomableContentView(
content: BaseMediaContent, content: BaseMediaContent,
images: ImmutableList<BaseMediaContent> = remember(content) { listOf(content).toImmutableList() }, images: ImmutableList<BaseMediaContent> = remember(content) { listOf(content).toImmutableList() },
@@ -154,13 +123,6 @@ fun ZoomableContentView(
// store the dialog open or close state // store the dialog open or close state
var dialogOpen by remember(content) { mutableStateOf(false) } 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 = var mainImageModifier =
if (roundedCorner) { if (roundedCorner) {
MaterialTheme.colorScheme.imageModifier MaterialTheme.colorScheme.imageModifier
@@ -170,30 +132,31 @@ fun ZoomableContentView(
if (content is MediaUrlContent) { if (content is MediaUrlContent) {
mainImageModifier = mainImageModifier =
mainImageModifier.combinedClickable( mainImageModifier.clickable(
onClick = { dialogOpen = true }, onClick = { dialogOpen = true },
onLongClick = { shareOpen.value = true },
) )
} else if (content is MediaPreloadedContent) { } else if (content is MediaPreloadedContent) {
mainImageModifier = mainImageModifier =
mainImageModifier.combinedClickable( mainImageModifier.clickable(
onClick = { dialogOpen = true }, onClick = { dialogOpen = true },
onLongClick = { shareOpen.value = true },
) )
} else { } else {
mainImageModifier = mainImageModifier.clickable { dialogOpen = true } mainImageModifier = mainImageModifier.clickable { dialogOpen = true }
} }
val controllerVisible = remember { mutableStateOf(true) }
when (content) { when (content) {
is MediaUrlImage -> is MediaUrlImage ->
SensitivityWarning(content.contentWarning != null, accountViewModel) { SensitivityWarning(content.contentWarning != null, accountViewModel) {
UrlImageView(content, mainImageModifier, isFiniteHeight, accountViewModel = accountViewModel) UrlImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
} }
is MediaUrlVideo -> is MediaUrlVideo ->
SensitivityWarning(content.contentWarning != null, accountViewModel) { SensitivityWarning(content.contentWarning != null, accountViewModel) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
VideoView( VideoView(
videoUri = content.url, videoUri = content.url,
mimeType = content.mimeType,
title = content.description, title = content.description,
artworkUri = content.artworkUri, artworkUri = content.artworkUri,
authorName = content.authorName, authorName = content.authorName,
@@ -208,12 +171,13 @@ fun ZoomableContentView(
} }
} }
is MediaLocalImage -> is MediaLocalImage ->
LocalImageView(content, mainImageModifier, isFiniteHeight, accountViewModel = accountViewModel) LocalImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
is MediaLocalVideo -> is MediaLocalVideo ->
content.localFile?.let { content.localFile?.let {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
VideoView( VideoView(
videoUri = it.toUri().toString(), videoUri = it.toUri().toString(),
mimeType = content.mimeType,
title = content.description, title = content.description,
artworkUri = content.artworkUri, artworkUri = content.artworkUri,
authorName = content.authorName, authorName = content.authorName,
@@ -233,11 +197,11 @@ fun ZoomableContentView(
} }
@Composable @Composable
private fun LocalImageView( fun LocalImageView(
content: MediaLocalImage, content: MediaLocalImage,
mainImageModifier: Modifier, mainImageModifier: Modifier,
isFiniteHeight: Boolean, isFiniteHeight: Boolean,
topPaddingForControllers: Dp = Dp.Unspecified, controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false, alwayShowImage: Boolean = false,
) { ) {
@@ -294,15 +258,15 @@ private fun LocalImageView(
SubcomposeAsyncImageContent() SubcomposeAsyncImageContent()
content.isVerified?.let { content.isVerified?.let {
val verifierModifier = AnimatedVisibility(
if (topPaddingForControllers.isSpecified) { visible = controllerVisible.value,
Modifier.align(Alignment.TopEnd).padding(top = topPaddingForControllers) modifier = Modifier,
} else { enter = remember { fadeIn() },
Modifier.align(Alignment.TopEnd) 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 @Composable
private fun UrlImageView( fun UrlImageView(
content: MediaUrlImage, content: MediaUrlImage,
mainImageModifier: Modifier, mainImageModifier: Modifier,
isFiniteHeight: Boolean, isFiniteHeight: Boolean,
topPaddingForControllers: Dp = Dp.Unspecified, controllerVisible: MutableState<Boolean>,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false, alwayShowImage: Boolean = false,
) { ) {
@@ -361,7 +325,7 @@ private fun UrlImageView(
model = content.url, model = content.url,
contentDescription = content.description, contentDescription = content.description,
contentScale = contentScale, contentScale = contentScale,
modifier = mainImageModifier, modifier = mainImageModifier.border(10.dp, Color.Red),
) { ) {
when (painter.state) { when (painter.state) {
is AsyncImagePainter.State.Loading, is AsyncImagePainter.State.Loading,
@@ -392,15 +356,15 @@ private fun UrlImageView(
is AsyncImagePainter.State.Success -> { is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent() SubcomposeAsyncImageContent()
val verifierModifier = AnimatedVisibility(
if (topPaddingForControllers.isSpecified) { visible = controllerVisible.value,
Modifier.align(Alignment.TopEnd).padding(top = topPaddingForControllers) modifier = Modifier.align(Alignment.TopEnd),
} else { enter = remember { fadeIn() },
Modifier.align(Alignment.TopEnd) exit = remember { fadeOut() },
) {
Box(Modifier.align(Alignment.TopEnd), contentAlignment = Alignment.TopEnd) {
ShowHash(content)
} }
Box(verifierModifier, contentAlignment = Alignment.TopEnd) {
ShowHash(content)
} }
} }
else -> {} else -> {}
@@ -634,217 +598,42 @@ fun DisplayBlurHash(
} }
@Composable @Composable
fun ZoomableImageDialog( fun ShareImageAction(
imageUrl: BaseMediaContent,
allImages: ImmutableList<BaseMediaContent> = 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<BaseMediaContent>,
imageUrl: BaseMediaContent,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val pagerState: PagerState = rememberPagerState { allImages.size }
val controllerVisible = remember { mutableStateOf(false) }
val holdOn = remember { mutableStateOf<Boolean>(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<String>,
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(
popupExpanded: MutableState<Boolean>, popupExpanded: MutableState<Boolean>,
content: BaseMediaContent, content: BaseMediaContent,
onDismiss: () -> Unit, 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<Boolean>,
videoUri: String?,
postNostrUri: String?,
mimeType: String?,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) { ) {
DropdownMenu( DropdownMenu(
expanded = popupExpanded.value, expanded = popupExpanded.value,
@@ -852,118 +641,101 @@ private fun ShareImageAction(
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
if (content is MediaUrlContent) { if (videoUri != null && !videoUri.startsWith("file")) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.copy_url_to_clipboard)) }, text = { Text(stringResource(R.string.copy_url_to_clipboard)) },
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(content.url)) clipboardManager.setText(AnnotatedString(videoUri))
onDismiss() 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( DropdownMenuItem(
text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) }, text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) },
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(content.uri)) clipboardManager.setText(AnnotatedString(it))
onDismiss() onDismiss()
}, },
) )
} }
}
}
@Composable if (videoUri != null) {
private fun RenderImageOrVideo( if (!videoUri.startsWith("file")) {
content: BaseMediaContent, val localContext = LocalContext.current
roundedCorner: Boolean,
isFiniteHeight: Boolean,
topPaddingForControllers: Dp = Dp.Unspecified,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onToggleControllerVisibility: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
) {
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
if (content is MediaUrlImage) { fun saveImage() {
val mainModifier = ImageSaver.saveImage(
Modifier context = localContext,
.fillMaxSize() url = videoUri,
.zoomable( onSuccess = {
rememberZoomState(), accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
onTap = { },
if (onToggleControllerVisibility != null) { onError = {
onToggleControllerVisibility() 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( fun saveImage() {
content = content, ImageSaver.saveImage(
mainImageModifier = mainModifier, context = localContext,
isFiniteHeight = isFiniteHeight, localFile = videoUri.toUri().toFile(),
topPaddingForControllers = topPaddingForControllers, mimeType = mimeType,
accountViewModel = accountViewModel, onSuccess = {
alwayShowImage = true, accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
) },
} else if (content is MediaUrlVideo) { onError = {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize(1f)) { accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
VideoViewInner( },
videoUri = content.url, )
title = content.description, }
artworkUri = content.artworkUri,
authorName = content.authorName, val writeStoragePermissionState =
roundedCorner = roundedCorner, rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
isFiniteHeight = isFiniteHeight, if (isGranted) {
topPaddingForControllers = topPaddingForControllers, saveImage()
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
)
}
} else if (content is MediaLocalImage) {
val mainModifier =
Modifier
.fillMaxSize()
.zoomable(
rememberZoomState(),
onTap = {
if (onToggleControllerVisibility != null) {
onToggleControllerVisibility()
} }
}, }
)
LocalImageView( DropdownMenuItem(
content = content, text = { Text(stringResource(R.string.save_to_gallery)) },
mainImageModifier = mainModifier, onClick = {
isFiniteHeight = isFiniteHeight, if (
topPaddingForControllers = topPaddingForControllers, Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
accountViewModel = accountViewModel, writeStoragePermissionState.status.isGranted
alwayShowImage = true, ) {
) saveImage()
} else if (content is MediaLocalVideo) { } else {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize(1f)) { writeStoragePermissionState.launchPermissionRequest()
content.localFile?.let { }
VideoViewInner( onDismiss()
videoUri = it.toUri().toString(), },
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
isFiniteHeight = isFiniteHeight,
topPaddingForControllers = topPaddingForControllers,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
) )
} }
} }
@@ -142,6 +142,7 @@ fun AudioTrackHeader(
cover?.let { cover -> cover?.let { cover ->
LoadThumbAndThenVideoView( LoadThumbAndThenVideoView(
videoUri = media, videoUri = media,
mimeType = null,
title = noteEvent.subject(), title = noteEvent.subject(),
thumbUri = cover, thumbUri = cover,
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
@@ -153,6 +154,7 @@ fun AudioTrackHeader(
} ?: run { } ?: run {
VideoView( VideoView(
videoUri = media, videoUri = media,
mimeType = null,
title = noteEvent.subject(), title = noteEvent.subject(),
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
roundedCorner = true, roundedCorner = true,
@@ -202,6 +204,7 @@ fun AudioHeader(
) { ) {
VideoView( VideoView(
videoUri = media, videoUri = media,
mimeType = null,
waveform = waveform, waveform = waveform,
title = noteEvent.subject(), title = noteEvent.subject(),
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
@@ -52,6 +52,7 @@ fun FileHeaderDisplay(
val description = event.content.ifEmpty { null } ?: event.alt() val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri() val uri = note.toNostrUri()
val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>( mutableStateOf<BaseMediaContent>(
if (isImage) { if (isImage) {
@@ -62,6 +63,7 @@ fun FileHeaderDisplay(
blurhash = blurHash, blurhash = blurHash,
dim = dimensions, dim = dimensions,
uri = uri, uri = uri,
mimeType = mimeType,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -72,6 +74,7 @@ fun FileHeaderDisplay(
dim = dimensions, dim = dimensions,
uri = uri, uri = uri,
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
) )
}, },
) )
@@ -152,6 +152,7 @@ fun RenderLiveActivityEventInner(
) { ) {
VideoView( VideoView(
videoUri = media, videoUri = media,
mimeType = null,
title = subject, title = subject,
artworkUri = cover, artworkUri = cover,
authorName = baseNote.author?.toBestDisplayName(), authorName = baseNote.author?.toBestDisplayName(),
@@ -87,6 +87,7 @@ fun VideoDisplay(
val description = event.content.ifBlank { null } ?: event.alt() val description = event.content.ifBlank { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri() val uri = note.toNostrUri()
val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>( mutableStateOf<BaseMediaContent>(
if (isImage) { if (isImage) {
@@ -97,6 +98,7 @@ fun VideoDisplay(
blurhash = blurHash, blurhash = blurHash,
dim = dimensions, dim = dimensions,
uri = uri, uri = uri,
mimeType = mimeType,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -107,6 +109,7 @@ fun VideoDisplay(
uri = uri, uri = uri,
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
artworkUri = event.thumb() ?: event.image(), artworkUri = event.thumb() ?: event.image(),
mimeType = mimeType,
) )
}, },
) )
@@ -52,6 +52,7 @@ fun JustVideoDisplay(
val description = event.content.ifEmpty { null } ?: event.alt() val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl) val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri() val uri = note.toNostrUri()
val mimeType = event.mimeType()
mutableStateOf<BaseMediaContent>( mutableStateOf<BaseMediaContent>(
if (isImage) { if (isImage) {
@@ -62,6 +63,7 @@ fun JustVideoDisplay(
blurhash = blurHash, blurhash = blurHash,
dim = dimensions, dim = dimensions,
uri = uri, uri = uri,
mimeType = mimeType,
) )
} else { } else {
MediaUrlVideo( MediaUrlVideo(
@@ -72,6 +74,7 @@ fun JustVideoDisplay(
dim = dimensions, dim = dimensions,
uri = uri, uri = uri,
authorName = note.author?.toBestDisplayName(), authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
) )
}, },
) )
@@ -125,6 +125,8 @@ import kotlin.time.measureTimedValue
val params: Array<out String>? = null, val params: Array<out String>? = null,
) : ToastMsg() ) : ToastMsg()
@Immutable class ThrowableToastMsg(val titleResId: Int, val msg: String? = null, val throwable: Throwable) : ToastMsg()
@Stable @Stable
class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao { class AccountViewModel(val account: Account, val settings: SettingsState) : ViewModel(), Dao {
val accountLiveData: LiveData<AccountState> = account.live.map { it } val accountLiveData: LiveData<AccountState> = account.live.map { it }
@@ -163,6 +165,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
viewModelScope.launch { toasts.emit(ResourceToastMsg(titleResId, resourceId)) } 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( fun toast(
titleResId: Int, titleResId: Int,
resourceId: Int, resourceId: Int,
@@ -96,6 +96,7 @@ val Size35dp = 35.dp
val Size40dp = 40.dp val Size40dp = 40.dp
val Size55dp = 55.dp val Size55dp = 55.dp
val Size75dp = 75.dp val Size75dp = 75.dp
val Size110dp = 110.dp
val HalfEndPadding = Modifier.padding(end = 5.dp) val HalfEndPadding = Modifier.padding(end = 5.dp)
val HalfStartPadding = Modifier.padding(start = 5.dp) val HalfStartPadding = Modifier.padding(start = 5.dp)
+2
View File
@@ -116,6 +116,7 @@
<string name="website_url">Website URL</string> <string name="website_url">Website URL</string>
<string name="ln_address">LN Address</string> <string name="ln_address">LN Address</string>
<string name="ln_url_outdated">LN URL (outdated)</string> <string name="ln_url_outdated">LN URL (outdated)</string>
<string name="save_to_gallery">Save to Gallery</string>
<string name="image_saved_to_the_gallery">Image saved to the gallery</string> <string name="image_saved_to_the_gallery">Image saved to the gallery</string>
<string name="failed_to_save_the_image">Failed to save the image</string> <string name="failed_to_save_the_image">Failed to save the image</string>
<string name="upload_image">Upload Image</string> <string name="upload_image">Upload Image</string>
@@ -580,6 +581,7 @@
<string name="copy_to_clipboard">Copy to clipboard</string> <string name="copy_to_clipboard">Copy to clipboard</string>
<string name="copy_npub_to_clipboard">Copy npub to clipboard</string> <string name="copy_npub_to_clipboard">Copy npub to clipboard</string>
<string name="share_or_save">Share or Save</string>
<string name="copy_url_to_clipboard">Copy URL to clipboard</string> <string name="copy_url_to_clipboard">Copy URL to clipboard</string>
<string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string> <string name="copy_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>
@@ -38,6 +38,7 @@ abstract class MediaUrlContent(
dim: String? = null, dim: String? = null,
blurhash: String? = null, blurhash: String? = null,
val uri: String? = null, val uri: String? = null,
val mimeType: String? = null,
) : BaseMediaContent(description, dim, blurhash) ) : BaseMediaContent(description, dim, blurhash)
@Immutable @Immutable
@@ -49,7 +50,8 @@ class MediaUrlImage(
dim: String? = null, dim: String? = null,
uri: String? = null, uri: String? = null,
val contentWarning: 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 @Immutable
class MediaUrlVideo( class MediaUrlVideo(
@@ -62,7 +64,8 @@ class MediaUrlVideo(
val authorName: String? = null, val authorName: String? = null,
blurhash: String? = null, blurhash: String? = null,
val contentWarning: 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 @Immutable
abstract class MediaPreloadedContent( abstract class MediaPreloadedContent(
@@ -61,6 +61,7 @@ class RichTextParser() {
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"], contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri, uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
) )
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54InlineMetadata().parse(fullUrl) val frags = Nip54InlineMetadata().parse(fullUrl)
@@ -73,6 +74,7 @@ class RichTextParser() {
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION], dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"], contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri, uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
) )
} else { } else {
null null