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

View File

@@ -304,6 +304,7 @@ fun EditPostView(
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView(
myUrlPreview,
mimeType = null,
roundedCorner = true,
isFiniteHeight = false,
accountViewModel = accountViewModel,

View File

@@ -251,6 +251,7 @@ fun ImageVideoPost(
postViewModel.galleryUri?.let {
VideoView(
videoUri = it.toString(),
mimeType = postViewModel.mediaType,
roundedCorner = false,
isFiniteHeight = false,
accountViewModel = accountViewModel,

View File

@@ -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)
}
}

View File

@@ -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<Int>? = 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<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
automaticallyStartPlayback: State<Boolean>,
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<MediaItemData, MediaItem>(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<Int>? = null,
keepPlaying: MutableState<Boolean>,
automaticallyStartPlayback: State<Boolean>,
@@ -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<Int>,
waveformProgress: MutableState<Float>,
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<Boolean>,
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<Boolean>,
controllerVisible: MutableState<Boolean>,
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<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 }
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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<BaseMediaContent> = 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<Boolean>,
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<Boolean>,
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<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(
fun ShareImageAction(
popupExpanded: MutableState<Boolean>,
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<Boolean>,
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<Boolean>(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()
},
)
}
}

View File

@@ -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(),

View File

@@ -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<BaseMediaContent>(
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,
)
},
)

View File

@@ -152,6 +152,7 @@ fun RenderLiveActivityEventInner(
) {
VideoView(
videoUri = media,
mimeType = null,
title = subject,
artworkUri = cover,
authorName = baseNote.author?.toBestDisplayName(),

View File

@@ -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<BaseMediaContent>(
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,
)
},
)

View File

@@ -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<BaseMediaContent>(
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,
)
},
)

View File

@@ -125,6 +125,8 @@ import kotlin.time.measureTimedValue
val params: Array<out String>? = 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<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)) }
}
fun toast(
titleResId: Int,
message: String?,
throwable: Throwable,
) {
viewModelScope.launch { toasts.emit(ThrowableToastMsg(titleResId, message, throwable)) }
}
fun toast(
titleResId: Int,
resourceId: Int,

View File

@@ -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)

View File

@@ -116,6 +116,7 @@
<string name="website_url">Website URL</string>
<string name="ln_address">LN Address</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="failed_to_save_the_image">Failed to save the 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_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_the_note_id_to_the_clipboard">Copy Note ID to clipboard</string>

View File

@@ -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(

View File

@@ -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